Compare commits
233 changed files with 46845 additions and 6426 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -1,3 +1,5 @@
|
|||
.directory
|
||||
result
|
||||
secrets
|
||||
/target
|
||||
/result
|
||||
/result-*
|
||||
/.tmp
|
||||
/.claude/settings.local.json
|
||||
|
|
|
|||
243
CLAUDE.md
Normal file
243
CLAUDE.md
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
# hyperhive — claude entry point
|
||||
|
||||
Hey claude. This is your starting page. The detailed docs live in
|
||||
[`docs/`](docs/) and are written for humans + you both — read them
|
||||
when you need depth on a subsystem. This file is the index.
|
||||
|
||||
- High-level project intro: **[README.md](README.md)**.
|
||||
- Open work + backlog: the **[forge issue
|
||||
tracker](http://localhost:3000/hyperhive/hyperhive/issues)**.
|
||||
- Operator/agent trust-boundary design:
|
||||
**[docs/boundary.md](docs/boundary.md)** (`area:ops` issues
|
||||
for the deployment/gateway/privsep work).
|
||||
- Credential isolation + sandbox threat model:
|
||||
**[docs/security.md](docs/security.md)**.
|
||||
|
||||
## File map
|
||||
|
||||
```
|
||||
hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
|
||||
src/main.rs clap setup; serve / spawn / kill / rebuild / list /
|
||||
pending / approve / deny / destroy [--purge] /
|
||||
request-spawn / set-parent (--parent / --root);
|
||||
periodic vacuum tasks
|
||||
src/server.rs host admin socket (HostRequest → dispatch)
|
||||
src/client.rs admin-socket client
|
||||
src/manager_server.rs manager-privileged socket (ManagerRequest)
|
||||
src/agent_server.rs per-sub-agent socket listener (long-poll Recv)
|
||||
src/broker.rs sqlite Message store + intra-process broadcast
|
||||
channel (`MessageEvent`) for `recv_blocking_batch` +
|
||||
the dashboard forwarder; hourly vacuum of acked>30d
|
||||
src/dashboard_events.rs unified wire-facing event channel feeding
|
||||
`/dashboard/stream`. Carries broker `Sent` /
|
||||
`Delivered` (mirrored by the forwarder task
|
||||
in main.rs) + mutation events
|
||||
(`ApprovalAdded` / `ApprovalResolved`,
|
||||
`QuestionAdded` / `QuestionResolved`,
|
||||
`TransientSet` / `TransientCleared`,
|
||||
`RebuildQueueChanged`). Each frame carries a
|
||||
monotonic per-process `seq` clients use to
|
||||
dedupe against snapshot reads.
|
||||
src/approvals.rs sqlite Approval queue + kinds
|
||||
src/operator_questions.rs sqlite question queue backing `ask` /
|
||||
`answer` (both operator + agent-to-agent)
|
||||
src/questions.rs shared dispatch for `Ask` / `Answer` —
|
||||
used by both agent + manager surfaces
|
||||
src/reminder_scheduler.rs 5s poll loop: drains due reminders,
|
||||
resolves file_path container→host, persists
|
||||
payload + delivers pointer string
|
||||
src/scheduled_prompts.rs sqlite layer for scheduled prompts —
|
||||
schema, CRUD helpers (`cancel_all`,
|
||||
`cancel_targets`, `reap_cancelled`), catch-up
|
||||
clamp on resume (#444)
|
||||
src/scheduled_prompts_worker.rs 5s poll loop: fires due rows,
|
||||
fans out one Message per active target,
|
||||
re-arms recurring rows, deletes fired one-shots
|
||||
src/events_vacuum.rs host-side hourly sweep of every agent's
|
||||
/state/hyperhive-events.sqlite
|
||||
src/crash_watch.rs poll every 10s; fire HelperEvent::ContainerCrash
|
||||
when a previously-running container disappears
|
||||
without an operator-initiated transient (or a
|
||||
RECENT_TRANSIENT_GRACE tombstone within the
|
||||
last 30s, closes #425)
|
||||
src/container_view.rs ContainerView struct + build_all helper;
|
||||
shared between dashboard.rs (cold-load via
|
||||
/api/state) and coordinator.rs's
|
||||
rescan_containers_and_emit
|
||||
src/coordinator.rs shared state (broker/approvals/operator_questions/
|
||||
transient/sockets) + tombstone enumeration +
|
||||
kick_agent + notify_agent (helper-event push) +
|
||||
last_containers cache + rescan_and_emit diff helper
|
||||
src/loose_ends.rs loose-ends aggregator (pending approvals +
|
||||
unanswered questions + pending reminders) —
|
||||
for_agent (filtered) and hive_wide (manager
|
||||
surface). Backs AgentRequest::GetLooseEnds +
|
||||
ManagerRequest::GetLooseEnds (the
|
||||
get_loose_ends MCP tool).
|
||||
src/rebuild_queue.rs global serialised queue for long-running ops
|
||||
(rebuild / meta_update / spawn / destroy).
|
||||
Single background worker drains FIFO; dedup
|
||||
collapses re-enqueued still-queued entries.
|
||||
`QueueEntry` carries id, agent, kind, state,
|
||||
source, parent_id (cascade grouping), timing,
|
||||
reason, error, step (current phase label while
|
||||
Running; cleared on finish). Emits
|
||||
`RebuildQueueChanged` snapshots on every
|
||||
mutation.
|
||||
src/actions.rs approve/deny/destroy (transient-aware)
|
||||
src/auto_update.rs startup rebuild scan + ensure_manager +
|
||||
meta::lock_update_hyperhive bump
|
||||
src/lifecycle.rs `nixos-container` shellouts; per-agent applied
|
||||
+ proposed git repo seeding; tag plumbing
|
||||
src/meta.rs single hive-c0re-owned flake at /var/lib/
|
||||
hyperhive/meta/ — sync_agents, two-phase
|
||||
prepare/finalize/abort, lock_update_*
|
||||
src/migrate.rs startup auto-migration from pre-meta layout
|
||||
(idempotent, marker-guarded phase 4)
|
||||
src/topology.rs agent parent/child storage at
|
||||
/var/lib/hyperhive/meta/topology.json — sole
|
||||
source of truth for who's the parent of whom
|
||||
(single source the dashboard, render_flake,
|
||||
and the eventual cap-enforcement plumbing all
|
||||
read). Reconciled by `meta::sync_agents`;
|
||||
operator/manager edits land via the
|
||||
eventual write API (#361 follow-ups).
|
||||
src/forge.rs optional Forgejo wiring: per-agent users +
|
||||
tokens, the `agent-configs` org (`push_config`),
|
||||
and meta read access; mirrors each applied repo
|
||||
into `agent-configs/<n>` (core-only); agents are
|
||||
read-only collaborators on `core/meta`
|
||||
src/dashboard.rs axum HTTP: /api/state JSON + actions
|
||||
+ journald viewer + bind-with-retry (SO_REUSEADDR)
|
||||
+ deployed_sha chip per container +
|
||||
/dashboard/{stream,history} subscribing to the
|
||||
unified DashboardEvent channel. Static assets
|
||||
(HTML/CSS/JS/favicon) served by
|
||||
tower_http::ServeDir from $HIVE_STATIC_DIR
|
||||
(= `${frontend}/dashboard` per the c0re module).
|
||||
|
||||
frontend/ npm workspaces (esbuild → static dist). Built
|
||||
hermetically by `nix/frontend.nix`
|
||||
(`packages.${system}.frontend`).
|
||||
packages/shared/ @hive/shared: terminal pane + Catppuccin palette
|
||||
+ base typography (was hive-fr0nt). ES module
|
||||
exporting { create, linkify }; pure JS, no IIFE
|
||||
globals; consumed by dashboard + agent.
|
||||
packages/dashboard/ @hive/dashboard SPA: src/{index.html, tabs.js,
|
||||
flow.html, flow.js, common.js, dashboard.css} +
|
||||
build.mjs → dist/{index.html, flow.html,
|
||||
static/{tabs.js, flow.js, dashboard.css,
|
||||
stream-worker.js}}.
|
||||
packages/agent/ @hive/agent default per-container UI: src/
|
||||
{index, stats, screen}.html + {app, stats}.js
|
||||
+ agent.css → dist/{*.html, static/*}.
|
||||
|
||||
hive-ag3nt/ in-container harness crate; produces TWO binaries
|
||||
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT
|
||||
src/client.rs generic JSON-line request/response over unix socket
|
||||
src/web_ui.rs per-container axum HTTP page (incl /api/cancel,
|
||||
/api/compact, /api/model, /events/history,
|
||||
/screen, /screen/ws)
|
||||
src/turn_stats.rs per-turn analytics sink (one sqlite row per
|
||||
turn at /state/hyperhive-turn-stats.sqlite);
|
||||
schema + best-effort writer
|
||||
src/stats.rs read-side aggregations over turn-stats.sqlite
|
||||
backing the /stats page (bucketed Snapshot:
|
||||
turns / duration / tokens / model mix)
|
||||
src/events.rs LiveEvent + broadcast Bus + sqlite-backed history
|
||||
(/state/hyperhive-events.sqlite) + TurnState +
|
||||
model selection (persisted at /state/hyperhive-model)
|
||||
src/turn.rs claude --print + stream-json pump; --compact retry;
|
||||
proactive compaction + auto session-reset
|
||||
src/mcp.rs embedded MCP server (rmcp): AgentServer + ManagerServer
|
||||
src/login.rs probe /root/.claude/ for a valid session
|
||||
src/login_session.rs drives `claude auth login` over stdio pipes
|
||||
src/bin/hive-ag3nt.rs sub-agent main (Serve + Mcp subcommands)
|
||||
src/bin/hive-m1nd.rs manager main (Serve + Mcp subcommands)
|
||||
Static UI assets served by ServeDir from
|
||||
$HIVE_STATIC_DIR (= hyperhive.frontend
|
||||
.mergedDist — default agent dist + per-agent
|
||||
extraFiles, set per the harness-base module).
|
||||
prompts/ static role/tools/settings for claude (include_str!):
|
||||
agent.md — sub-agent system prompt
|
||||
manager.md — manager system prompt
|
||||
claude-settings.json — --settings JSON
|
||||
|
||||
hive-forge/ Forgejo CLI wrapper (`hive-forge` binary)
|
||||
src/main.rs clap dispatch over the verbs/
|
||||
src/client.rs blocking reqwest client (Forgejo REST API)
|
||||
src/body.rs body input resolution (--body / --body-file / piped stdin)
|
||||
src/verbs/<verb>.rs one module per verb (view, issue, pr, comment,
|
||||
comments, comment-show, comment-edit, issue-create,
|
||||
issue-edit, pr-create, pr-reviews, assign,
|
||||
close, labels, milestone, branches,
|
||||
tree-sha, diff, subscription, attach-issue,
|
||||
attach-comment, lint). Replaces the 600-line
|
||||
hive-forge-tools.nix bash script (closes #280).
|
||||
|
||||
hive-sh4re/ wire types (HostRequest/Response, AgentRequest/Response,
|
||||
ManagerRequest/Response, Message, Approval, HelperEvent)
|
||||
|
||||
nix/
|
||||
modules/hive-c0re.nix systemd service + firewall + git wiring;
|
||||
`contextWindowTokens` attrset (per-model,
|
||||
injected as env vars into all containers);
|
||||
imports hive-forge.nix
|
||||
modules/hive-forge.nix optional in-container Forgejo
|
||||
(`hyperhive.forge.enable`, default on);
|
||||
Catppuccin Mocha theme via tmpfiles C+ copy
|
||||
templates/harness-base.nix shared scaffolding for sub-agents + manager;
|
||||
`hyperhive.model` option (HIVE_DEFAULT_MODEL)
|
||||
templates/agent-base.nix sub-agent nixosConfiguration
|
||||
templates/manager.nix manager nixosConfiguration
|
||||
templates/weston-vnc.nix optional `hyperhive.gui.enable`
|
||||
— weston + VNC backend systemd unit; writes
|
||||
/etc/hyperhive/gui.json (vnc_port + auth) for
|
||||
the harness WebSocket relay (/screen/ws)
|
||||
forge-theme/theme-catppuccin-vibec0re.css Catppuccin Mocha forge theme
|
||||
|
||||
docs/
|
||||
conventions.md naming, identity=socket, async forms, commit style
|
||||
gotchas.md NixOS / nspawn quirks and lessons learned
|
||||
web-ui.md dashboard + per-agent page layouts and endpoints
|
||||
turn-loop.md claude invocation, wake prompt, MCP tool surface
|
||||
approvals.md approval flow, manager policy, helper events
|
||||
persistence.md sqlite dbs, retention, state dir layout
|
||||
terminal-rendering.md per-agent terminal row taxonomy (as built)
|
||||
boundary.md operator/agent trust model rationale
|
||||
agent-hierarchy.md tree-shape topology design + manager-privilege audit (#361)
|
||||
damocles-migration.md future migration plan for damocles → hyperhive
|
||||
```
|
||||
|
||||
## Reading paths
|
||||
|
||||
Pick the doc that matches your task. None depend on the others —
|
||||
read them à la carte.
|
||||
|
||||
- **"What does the dashboard look like?"** →
|
||||
[`docs/web-ui.md`](docs/web-ui.md).
|
||||
- **"How does the per-agent terminal classify + colour
|
||||
events?"** → [`docs/terminal-rendering.md`](docs/terminal-rendering.md).
|
||||
- **"How does claude get its prompt and what tools does it have?"** →
|
||||
[`docs/turn-loop.md`](docs/turn-loop.md).
|
||||
- **"How do config changes flow from manager to operator to
|
||||
container?"** → [`docs/approvals.md`](docs/approvals.md).
|
||||
- **"What state survives destroy / purge / restart?"** →
|
||||
[`docs/persistence.md`](docs/persistence.md).
|
||||
- **"Naming, commit style, wire protocol, the `data-async`
|
||||
pattern."** → [`docs/conventions.md`](docs/conventions.md).
|
||||
- **"Why does the nspawn flag look like that?"** →
|
||||
[`docs/gotchas.md`](docs/gotchas.md).
|
||||
|
||||
## Conventions & process
|
||||
|
||||
The docs below own the details — this section just points at them.
|
||||
|
||||
- **Commit style, naming, identity, reconcile verb:** →
|
||||
[`docs/conventions.md`](docs/conventions.md).
|
||||
- **NixOS / nspawn quirks** (bind mounts, conf flags, etc.): →
|
||||
[`docs/gotchas.md`](docs/gotchas.md).
|
||||
- **Turn loop, sentinels (rate-limit, auth-failed), context
|
||||
window:** → [`docs/turn-loop.md`](docs/turn-loop.md).
|
||||
- **Two-step spawn, approval flow, flake.lock validation:** →
|
||||
[`docs/approvals.md`](docs/approvals.md).
|
||||
2375
Cargo.lock
generated
Normal file
2375
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
48
Cargo.toml
Normal file
48
Cargo.toml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-sh4re"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
version = "0.1.0"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
# Tolerated stylistic pedantic lints (noisy, not actionable).
|
||||
missing_errors_doc = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
module_name_repetitions = "allow"
|
||||
must_use_candidate = "allow"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
hive-sh4re = { path = "hive-sh4re" }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
rmcp = { version = "1.7", default-features = false, features = [
|
||||
"server",
|
||||
"macros",
|
||||
"transport-io",
|
||||
] }
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
schemars = "1.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
similar = "2"
|
||||
tokio = { version = "1", features = [
|
||||
"fs",
|
||||
"io-util",
|
||||
"macros",
|
||||
"net",
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"sync",
|
||||
"time",
|
||||
] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
339
LICENSE
339
LICENSE
|
|
@ -1,339 +0,0 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
155
README.md
155
README.md
|
|
@ -1,46 +1,143 @@
|
|||
# nixos-configuration
|
||||
# <img src="branding/hyperhive.svg" alt="" width="38" align="top"> hyperhive
|
||||
|
||||
Personal NixOS configuration for all machines. Devices are declared in `devices.nix`, per-device configs live in `nixosConfigurations/<name>/`, and shared modules in `nixosModules/`.
|
||||
> a swarm of claude-code agents, each in its own nspawn cage, gossiping
|
||||
> over unix sockets. config changes flow as git commits, the operator
|
||||
> approves them in a browser, every deploy is a tag. cyberpunk-themed
|
||||
> dashboard included. 💜⚡
|
||||
|
||||
## Distributed builds
|
||||
Claude code is great in one window, *exponentielle* across many — but
|
||||
only if you can keep the agents from stepping on each other, give them
|
||||
durable identity, and stop them from eating production. hyperhive is
|
||||
the substrate.
|
||||
|
||||
Machines are configured to act as build servers / binary caches for each other in devices.nix.
|
||||
- identity = unix socket
|
||||
- communication = sqlite-backed broker (`send` / `recv` / `ask` /
|
||||
`answer` / `remind`)
|
||||
- config = git (manager proposes, operator approves, deploys land as
|
||||
tagged commits)
|
||||
- blast radius = container
|
||||
|
||||
### Onboarding a device as a build client
|
||||
```
|
||||
host (NixOS, runs hive-c0re.service)
|
||||
│
|
||||
├── operator
|
||||
│ ├── browser → :7000 hive-c0re dashboard
|
||||
│ ├── browser → :8000 / :8100-8999 per-agent web UIs
|
||||
│ └── CLI → /run/hyperhive/host.sock admin protocol
|
||||
│
|
||||
├── hive-c0re (Rust daemon: lifecycle / broker / approvals /
|
||||
│ auto-update / dashboard / sockets)
|
||||
│
|
||||
└── nixos-containers
|
||||
├── hm1nd manager agent (privileged MCP surface)
|
||||
└── h-<name> sub-agent (vanilla MCP surface + per-agent extras)
|
||||
```
|
||||
|
||||
1. Generate a key pair on the device:
|
||||
Depth lives in [`docs/`](docs/) — pick the one matching your task:
|
||||
|
||||
```sh
|
||||
sudo ssh-keygen -t ed25519 -f /etc/nix/distributed-build-key -N "" -C "$(hostname)-nix-builds" && sudo cat /etc/nix/distributed-build-key.pub
|
||||
```
|
||||
| reading path | doc |
|
||||
| --- | --- |
|
||||
| dashboard layout + endpoints | [`docs/web-ui.md`](docs/web-ui.md) |
|
||||
| claude turn loop + MCP tools | [`docs/turn-loop.md`](docs/turn-loop.md) |
|
||||
| config-edit + approval state machine | [`docs/approvals.md`](docs/approvals.md) |
|
||||
| what survives destroy / purge / restart | [`docs/persistence.md`](docs/persistence.md) |
|
||||
| naming, wire protocol, commit style | [`docs/conventions.md`](docs/conventions.md) |
|
||||
| NixOS / nspawn gotchas | [`docs/gotchas.md`](docs/gotchas.md) |
|
||||
|
||||
2. Add the public key to the device entry in `devices.nix`:
|
||||
## Host config
|
||||
|
||||
```nix
|
||||
distributedBuilds.clientPublicKey = "ssh-ed25519 AAAA... <hostname>-nix-builds";
|
||||
```
|
||||
Minimal `flake.nix` for a host that runs hive-c0re:
|
||||
|
||||
3. Rebuild all build machines so they pick up the new authorized key.
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||
hyperhive.url = "git+https://git.berlin.ccc.de/vinzenz/hyperhive";
|
||||
};
|
||||
|
||||
### Adding a build server
|
||||
outputs = { nixpkgs, hyperhive, ... }: {
|
||||
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
hyperhive.nixosModules.default # hive-c0re + hive-forge in one import
|
||||
({ ... }: {
|
||||
services.hive-c0re.enable = true;
|
||||
# services.hive-c0re.operatorPronouns = "they/them"; # default: "she/her"
|
||||
|
||||
1. Add to its entry in `devices.nix`:
|
||||
# ... rest of your host config
|
||||
system.stateVersion = "25.11";
|
||||
})
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```nix
|
||||
distributedBuilds.isBuilder = true;
|
||||
distributedBuilds.hostPublicKey = "ssh-ed25519 AAAA..."; # from: ssh-keyscan -t ed25519 "$(hostname)"
|
||||
```
|
||||
hive-c0re opens its admin socket + dashboard, auto-creates the
|
||||
manager container, and auto-rebuilds any container whose hyperhive
|
||||
rev goes stale. `claude-code` is unfree — hyperhive scopes the
|
||||
whitelist to itself, nothing for the operator to set.
|
||||
|
||||
2. Generate a store signing key on the builder:
|
||||
Optional: set `services.hive-c0re.preBuildAgentTemplates = true;`
|
||||
to pre-fetch the per-container system closures into your host's
|
||||
/nix/store as part of `nixos-rebuild`. First-agent-spawn then
|
||||
completes in seconds instead of minutes (no nixpkgs/claude-code
|
||||
fetch on the critical path), at the cost of a few GB extra in your
|
||||
system closure. Off by default (the toplevels are pinned to
|
||||
`x86_64-linux`, so non-x86 hosts would otherwise force a cross-build).
|
||||
Alternatively warm the store manually:
|
||||
`nix build github:vinzenz/hyperhive#agent-base-toplevel`.
|
||||
|
||||
```sh
|
||||
sudo nix key generate-secret --key-name "$(hostname)" | sudo tee /etc/nix/signing-key.sec | sudo nix key convert-secret-to-public
|
||||
```
|
||||
## Agent configuration
|
||||
|
||||
3. Add the printed public key to `devices.nix`:
|
||||
Per-agent settings live in each agent's `agent.nix` and are synced to
|
||||
the container as environment variables. Common options:
|
||||
|
||||
```nix
|
||||
distributedBuilds.storeSigningPublicKey = "<hostname>:<base64...>";
|
||||
```
|
||||
- **`hyperhive.model`** — Claude model for this agent (default: `"haiku"`).
|
||||
Sets `HIVE_DEFAULT_MODEL` in the container; the harness applies it at
|
||||
boot and it takes priority over any persisted runtime override. The
|
||||
operator can still switch the model at runtime via the per-agent web UI,
|
||||
but that choice is reset by any rebuild that changes this option.
|
||||
- **`hyperhive.allowedRecipients`** — List of agent names this agent can
|
||||
message (via `send`). If unset, all agents are allowed. Useful to
|
||||
restrict an agent to talking only to the manager.
|
||||
- **`hyperhive.forge.url`** — Base URL of the hyperhive-managed Forgejo
|
||||
(default: `"http://localhost:3000"`). Used to configure the agent's
|
||||
tea login at boot; no-op if `/state/forge-token` is missing.
|
||||
- **`hyperhive.forge.keepSubscriptions`** — Boolean. If `true`, the agent's
|
||||
forge repo subscriptions are never auto-cleaned during rebuild; useful
|
||||
for agents that want to watch specific repos. Rendered as
|
||||
`HIVE_FORGE_KEEP_SUBSCRIPTIONS`.
|
||||
- **`hyperhive.forge.skipNotifyReasons`** — List of forge notification
|
||||
`reason` values to suppress (e.g. `[ "subscribed" "participating" ]`).
|
||||
Notifications matching these reasons are silently dropped; all others
|
||||
including direct mentions and reviews are delivered. Empty list (default)
|
||||
delivers all notifications. Rendered as `HIVE_FORGE_NOTIFY_SKIP_REASONS`
|
||||
(comma-separated).
|
||||
- **`hyperhive.frontend.dist`** — Override the default frontend package
|
||||
(`pkgs.hyperhive-frontend`, built by `nix/frontend.nix`). Set to a custom
|
||||
derivation to ship a fully custom per-agent SPA. The JSON contract
|
||||
(`/api/state`, `/events/stream`, action endpoints) is the source of truth
|
||||
for any replacement.
|
||||
- **`hyperhive.frontend.extraFiles`** — Attrset of extra files/directories
|
||||
to layer on top of the default agent dist. Each entry has a `source` (nix
|
||||
path) and an optional `target` (URL prefix in the static tree, defaults to
|
||||
the attribute name). Example: `{ bitburner.source = ./bitburner-dist; }`
|
||||
serves that dist at `/bitburner/`. Pure additions only — overwriting an
|
||||
existing default file is a hard eval-time error; use `frontend.dist` to
|
||||
replace the whole dist. Paths with leading `/` or `..` segments are
|
||||
rejected at eval time.
|
||||
|
||||
4. Rebuild all machines so they trust the new signing key.
|
||||
See `nix/templates/harness-base.nix` for the full list of options and
|
||||
their descriptions.
|
||||
|
||||
## Build / deploy
|
||||
|
||||
```sh
|
||||
nix develop -c cargo check
|
||||
nix flake check # rust + nix + toml fmt + clippy
|
||||
|
||||
# deploy from a host config that imports hyperhive.nixosModules.hive-c0re
|
||||
nix flake update --update-input hyperhive
|
||||
sudo nixos-rebuild switch --flake .#<host>
|
||||
```
|
||||
|
|
|
|||
6
TODO.md
Normal file
6
TODO.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Hyperhive TODOs
|
||||
|
||||
The backlog moved to the forge issue tracker:
|
||||
<http://localhost:3000/hyperhive/hyperhive/issues>
|
||||
|
||||
Operator/agent trust-boundary design rationale: [`docs/boundary.md`](docs/boundary.md).
|
||||
116
branding/agent-configs.svg
Normal file
116
branding/agent-configs.svg
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<svg width="300" height="300" viewBox="0 0 300 300" role="img" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>HyperHive · agent-configs</title>
|
||||
<desc>HyperHive agent-configs org icon — stacked config files, amber on dark, same frame as the main hyperhive mark</desc>
|
||||
<defs>
|
||||
<clipPath id="clipH"><circle cx="150" cy="150" r="140"/></clipPath>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#clipH)">
|
||||
<!-- same dark base + faint horizontal scanlines as the parent
|
||||
hyperhive mark, so the agent-configs org reads as a sibling -->
|
||||
<rect x="0" y="0" width="300" height="300" fill="#0a0600"/>
|
||||
<g stroke="#ffb300" stroke-width="0.35" opacity="0.07">
|
||||
<line x1="0" x2="300" y1="10" y2="10"/> <line x1="0" x2="300" y1="20" y2="20"/>
|
||||
<line x1="0" x2="300" y1="30" y2="30"/> <line x1="0" x2="300" y1="40" y2="40"/>
|
||||
<line x1="0" x2="300" y1="50" y2="50"/> <line x1="0" x2="300" y1="60" y2="60"/>
|
||||
<line x1="0" x2="300" y1="70" y2="70"/> <line x1="0" x2="300" y1="80" y2="80"/>
|
||||
<line x1="0" x2="300" y1="90" y2="90"/> <line x1="0" x2="300" y1="100" y2="100"/>
|
||||
<line x1="0" x2="300" y1="110" y2="110"/> <line x1="0" x2="300" y1="120" y2="120"/>
|
||||
<line x1="0" x2="300" y1="130" y2="130"/> <line x1="0" x2="300" y1="140" y2="140"/>
|
||||
<line x1="0" x2="300" y1="150" y2="150"/> <line x1="0" x2="300" y1="160" y2="160"/>
|
||||
<line x1="0" x2="300" y1="170" y2="170"/> <line x1="0" x2="300" y1="180" y2="180"/>
|
||||
<line x1="0" x2="300" y1="190" y2="190"/> <line x1="0" x2="300" y1="200" y2="200"/>
|
||||
<line x1="0" x2="300" y1="210" y2="210"/> <line x1="0" x2="300" y1="220" y2="220"/>
|
||||
<line x1="0" x2="300" y1="230" y2="230"/> <line x1="0" x2="300" y1="240" y2="240"/>
|
||||
<line x1="0" x2="300" y1="250" y2="250"/> <line x1="0" x2="300" y1="260" y2="260"/>
|
||||
<line x1="0" x2="300" y1="270" y2="270"/> <line x1="0" x2="300" y1="280" y2="280"/>
|
||||
</g>
|
||||
|
||||
<!-- containment rings, same as parent (visual family) -->
|
||||
<circle cx="150" cy="150" r="118" fill="none" stroke="#ffb300" stroke-width="1" opacity="0.4"/>
|
||||
<circle cx="150" cy="150" r="104" fill="none" stroke="#ff8f00" stroke-width="0.5" opacity="0.25" stroke-dasharray="4 6"/>
|
||||
|
||||
<!-- stack of three offset config sheets. each sheet is a
|
||||
dark-fill rectangle outlined in amber, with thin "content
|
||||
lines" inside to read as a file. offset diagonally so the
|
||||
stack reads top-down: back / mid / front. front sheet is
|
||||
centred on the canvas (150, 150) so the curly-brace glyph
|
||||
at canvas-centre lands at the visual middle of the front
|
||||
sheet; back and mid peek out top-LEFT so the stack reads
|
||||
as a pile. -->
|
||||
|
||||
<!-- BACK sheet (x=70, y=56 — top-left peek) -->
|
||||
<g opacity="0.55">
|
||||
<rect x="70" y="56" width="120" height="148" rx="6" ry="6"
|
||||
fill="#150c00" stroke="#ffb300" stroke-width="1.4"/>
|
||||
<g stroke="#ffb300" stroke-width="0.7" opacity="0.55">
|
||||
<line x1="82" y1="78" x2="164" y2="78"/>
|
||||
<line x1="82" y1="94" x2="144" y2="94"/>
|
||||
<line x1="82" y1="110" x2="158" y2="110"/>
|
||||
<line x1="82" y1="126" x2="132" y2="126"/>
|
||||
<line x1="82" y1="142" x2="164" y2="142"/>
|
||||
<line x1="82" y1="158" x2="144" y2="158"/>
|
||||
<line x1="82" y1="174" x2="154" y2="174"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- MID sheet (x=80, y=66) -->
|
||||
<g opacity="0.8">
|
||||
<rect x="80" y="66" width="120" height="148" rx="6" ry="6"
|
||||
fill="#1a0f00" stroke="#ffb300" stroke-width="1.6"/>
|
||||
<g stroke="#ffb300" stroke-width="0.8" opacity="0.7">
|
||||
<line x1="92" y1="88" x2="174" y2="88"/>
|
||||
<line x1="92" y1="104" x2="154" y2="104"/>
|
||||
<line x1="92" y1="120" x2="168" y2="120"/>
|
||||
<line x1="92" y1="136" x2="142" y2="136"/>
|
||||
<line x1="92" y1="152" x2="174" y2="152"/>
|
||||
<line x1="92" y1="168" x2="154" y2="168"/>
|
||||
<line x1="92" y1="184" x2="164" y2="184"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- FRONT sheet (x=90, y=76, width=120, height=148 — centred
|
||||
at canvas (150, 150)). Folded top-right corner + curly-
|
||||
brace glyph. The brace text is anchored at canvas-centre
|
||||
which now coincides with the front-sheet centre. -->
|
||||
<g>
|
||||
<!-- main rectangle with folded top-right corner cut: draw as a
|
||||
path so the corner triangle reads as a tab -->
|
||||
<path d="M 90 76
|
||||
L 192 76
|
||||
L 210 94
|
||||
L 210 224
|
||||
L 90 224
|
||||
Z"
|
||||
fill="#1f1200" stroke="#ffb300" stroke-width="1.8"/>
|
||||
<!-- folded-corner triangle (lighter fill) -->
|
||||
<path d="M 192 76 L 210 94 L 192 94 Z"
|
||||
fill="#2a1900" stroke="#ffb300" stroke-width="1.2"/>
|
||||
<!-- curly braces — the universal "config file" glyph.
|
||||
dominant-baseline=central centres the glyph block on the
|
||||
text y-coordinate so it lands at the front-sheet middle.
|
||||
font-size dropped 78→56 + tighter letter-spacing (#424
|
||||
mara: "braces cross the boundaries of the page") so the
|
||||
glyphs sit comfortably inside the 120-wide sheet with
|
||||
clear breathing room on the left/right edges. -->
|
||||
<text x="150" y="150" text-anchor="middle" dominant-baseline="central"
|
||||
font-family="ui-monospace, 'JetBrains Mono', monospace"
|
||||
font-size="56" font-weight="700" letter-spacing="-3"
|
||||
fill="#ffb300" opacity="0.95">{ }</text>
|
||||
</g>
|
||||
|
||||
<!-- thin centerline crosshair, same flourish as the parent -->
|
||||
<rect x="0" y="143" width="300" height="2" fill="#ffb300" opacity="0.07"/>
|
||||
</g>
|
||||
|
||||
<!-- same outer frame + corner brackets as the parent hyperhive
|
||||
mark so the two orgs read as a visual family -->
|
||||
<circle cx="150" cy="150" r="140" fill="none" stroke="#ffb300" stroke-width="2.5"/>
|
||||
<circle cx="150" cy="150" r="145" fill="none" stroke="#ff8f00" stroke-width="0.5" opacity="0.4" stroke-dasharray="8 4"/>
|
||||
<g stroke="#ffb300" stroke-width="1.5" fill="none" opacity="0.8">
|
||||
<path d="M44,44 L20,44 L20,70"/>
|
||||
<path d="M256,44 L280,44 L280,70"/>
|
||||
<path d="M44,256 L20,256 L20,230"/>
|
||||
<path d="M256,256 L280,256 L280,230"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
BIN
branding/hyperhive.png
Normal file
BIN
branding/hyperhive.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
97
branding/hyperhive.svg
Normal file
97
branding/hyperhive.svg
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<svg width="300" height="300" viewBox="0 0 300 300" role="img" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>HyperHive</title>
|
||||
<desc>HyperHive icon — hexagonal hive, amber on dark</desc>
|
||||
<defs>
|
||||
<clipPath id="clipH"><circle cx="150" cy="150" r="140"/></clipPath>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#clipH)">
|
||||
<rect x="0" y="0" width="300" height="300" fill="#0a0600"/>
|
||||
<g stroke="#ffb300" stroke-width="0.35" opacity="0.07">
|
||||
<line x1="0" x2="300" y1="10" y2="10"/> <line x1="0" x2="300" y1="20" y2="20"/>
|
||||
<line x1="0" x2="300" y1="30" y2="30"/> <line x1="0" x2="300" y1="40" y2="40"/>
|
||||
<line x1="0" x2="300" y1="50" y2="50"/> <line x1="0" x2="300" y1="60" y2="60"/>
|
||||
<line x1="0" x2="300" y1="70" y2="70"/> <line x1="0" x2="300" y1="80" y2="80"/>
|
||||
<line x1="0" x2="300" y1="90" y2="90"/> <line x1="0" x2="300" y1="100" y2="100"/>
|
||||
<line x1="0" x2="300" y1="110" y2="110"/> <line x1="0" x2="300" y1="120" y2="120"/>
|
||||
<line x1="0" x2="300" y1="130" y2="130"/> <line x1="0" x2="300" y1="140" y2="140"/>
|
||||
<line x1="0" x2="300" y1="150" y2="150"/> <line x1="0" x2="300" y1="160" y2="160"/>
|
||||
<line x1="0" x2="300" y1="170" y2="170"/> <line x1="0" x2="300" y1="180" y2="180"/>
|
||||
<line x1="0" x2="300" y1="190" y2="190"/> <line x1="0" x2="300" y1="200" y2="200"/>
|
||||
<line x1="0" x2="300" y1="210" y2="210"/> <line x1="0" x2="300" y1="220" y2="220"/>
|
||||
<line x1="0" x2="300" y1="230" y2="230"/> <line x1="0" x2="300" y1="240" y2="240"/>
|
||||
<line x1="0" x2="300" y1="250" y2="250"/> <line x1="0" x2="300" y1="260" y2="260"/>
|
||||
<line x1="0" x2="300" y1="270" y2="270"/> <line x1="0" x2="300" y1="280" y2="280"/>
|
||||
</g>
|
||||
|
||||
<circle cx="150" cy="150" r="118" fill="none" stroke="#ffb300" stroke-width="1" opacity="0.4"/>
|
||||
<circle cx="150" cy="150" r="104" fill="none" stroke="#ff8f00" stroke-width="0.5" opacity="0.25" stroke-dasharray="4 6"/>
|
||||
|
||||
<!-- RING 2 — 6 between-axis hexes, centered on (150,150) instead of (170,170) -->
|
||||
<g fill="#0e0800" stroke="#ffb300" stroke-width="0.7" opacity="0.4">
|
||||
<polygon points="254,105 241,127.5 215,127.5 202,105 215,82.5 241,82.5"/>
|
||||
<polygon points="98,105 85,127.5 59,127.5 46,105 59,82.5 85,82.5"/>
|
||||
<polygon points="176,60 163,82.5 137,82.5 124,60 137,37.5 163,37.5"/>
|
||||
<polygon points="254,195 241,217.5 215,217.5 202,195 215,172.5 241,172.5"/>
|
||||
<polygon points="98,195 85,217.5 59,217.5 46,195 59,172.5 85,172.5"/>
|
||||
<polygon points="176,240 163,262.5 137,262.5 124,240 137,217.5 163,217.5"/>
|
||||
</g>
|
||||
|
||||
<!-- RING 1 — 6 hexes -->
|
||||
<g fill="#150c00" stroke="#ffb300" stroke-width="1.2" opacity="0.8">
|
||||
<polygon points="228,150 215,172.5 189,172.5 176,150 189,127.5 215,127.5"/>
|
||||
<polygon points="202,105 189,127.5 163,127.5 150,105 163,82.5 189,82.5"/>
|
||||
<polygon points="150,105 137,127.5 111,127.5 98,105 111,82.5 137,82.5"/>
|
||||
<polygon points="124,150 111,172.5 85,172.5 72,150 85,127.5 111,127.5"/>
|
||||
<polygon points="150,195 137,217.5 111,217.5 98,195 111,172.5 137,172.5"/>
|
||||
<polygon points="202,195 189,217.5 163,217.5 150,195 163,172.5 189,172.5"/>
|
||||
</g>
|
||||
|
||||
<!-- CENTER hex -->
|
||||
<polygon points="176,150 163,172.5 137,172.5 124,150 137,127.5 163,127.5"
|
||||
fill="#1a0f00" stroke="#ffb300" stroke-width="1.8"/>
|
||||
|
||||
<!-- connections -->
|
||||
<g stroke="#ffb300" stroke-width="1" opacity="0.6">
|
||||
<line x1="176" y1="150" x2="202" y2="150"/>
|
||||
<line x1="124" y1="150" x2="98" y2="150"/>
|
||||
<line x1="163" y1="128" x2="176" y2="105"/>
|
||||
<line x1="137" y1="128" x2="124" y2="105"/>
|
||||
<line x1="163" y1="173" x2="176" y2="195"/>
|
||||
<line x1="137" y1="173" x2="124" y2="195"/>
|
||||
</g>
|
||||
|
||||
<!-- CENTER node -->
|
||||
<circle cx="150" cy="150" r="10" fill="#ffb300" opacity="0.95"/>
|
||||
<circle cx="150" cy="150" r="5" fill="#0a0600"/>
|
||||
|
||||
<!-- ring 1 nodes -->
|
||||
<g fill="#ff8f00" opacity="0.9">
|
||||
<circle cx="202" cy="150" r="6"/>
|
||||
<circle cx="176" cy="105" r="6"/>
|
||||
<circle cx="124" cy="105" r="6"/>
|
||||
<circle cx="98" cy="150" r="6"/>
|
||||
<circle cx="124" cy="195" r="6"/>
|
||||
<circle cx="176" cy="195" r="6"/>
|
||||
</g>
|
||||
<g fill="#0a0600">
|
||||
<circle cx="202" cy="150" r="2.5"/>
|
||||
<circle cx="176" cy="105" r="2.5"/>
|
||||
<circle cx="124" cy="105" r="2.5"/>
|
||||
<circle cx="98" cy="150" r="2.5"/>
|
||||
<circle cx="124" cy="195" r="2.5"/>
|
||||
<circle cx="176" cy="195" r="2.5"/>
|
||||
</g>
|
||||
|
||||
<rect x="0" y="143" width="300" height="2" fill="#ffb300" opacity="0.07"/>
|
||||
</g>
|
||||
|
||||
<circle cx="150" cy="150" r="140" fill="none" stroke="#ffb300" stroke-width="2.5"/>
|
||||
<circle cx="150" cy="150" r="145" fill="none" stroke="#ff8f00" stroke-width="0.5" opacity="0.4" stroke-dasharray="8 4"/>
|
||||
<g stroke="#ffb300" stroke-width="1.5" fill="none" opacity="0.8">
|
||||
<path d="M44,44 L20,44 L20,70"/>
|
||||
<path d="M256,44 L280,44 L280,70"/>
|
||||
<path d="M44,256 L20,256 L20,230"/>
|
||||
<path d="M256,256 L280,256 L280,230"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
80
devices.nix
80
devices.nix
|
|
@ -1,80 +0,0 @@
|
|||
{ self }:
|
||||
let
|
||||
nixos-raspberrypi = self.inputs.nixos-raspberrypi;
|
||||
in
|
||||
{
|
||||
# keep-sorted start block=yes
|
||||
aur0ra = {
|
||||
system = "aarch64-linux";
|
||||
nixosSystem = nixos-raspberrypi.lib.nixosSystem;
|
||||
};
|
||||
aur0ra-installer = {
|
||||
# build with nix build .\#nixosConfigurations.aur0ra-installer.config.system.build.sdImage
|
||||
system = "aarch64-linux";
|
||||
nixosSystem = nixos-raspberrypi.lib.nixosInstaller;
|
||||
};
|
||||
damocles = {
|
||||
system = "x86_64-linux";
|
||||
distributedBuilds.maxJobs = 0;
|
||||
};
|
||||
damocles-lab = {
|
||||
system = "x86_64-linux";
|
||||
distributedBuilds.maxJobs = 0;
|
||||
};
|
||||
epimetheus = {
|
||||
system = "aarch64-linux";
|
||||
};
|
||||
forgejo-runner-1 = {
|
||||
system = "aarch64-linux";
|
||||
publicFqdn = "forgejo-runner-1.dev.zerforschen.plus";
|
||||
distributedBuilds = {
|
||||
isBuilder = true;
|
||||
speedFactor = 1;
|
||||
clientPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK0NLgg0sFobBWz/bjYs9WkrMvlcvJC5F6+3jQ/b+AnD forgejo-runner-1-nix-builds";
|
||||
hostPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIANGC89GiT5xCsFICwrharrbV3q7acWHqk6ZwOUXbtGT";
|
||||
storeSigningPublicKey = "forgejo-runner-1:ln1FVLL8G5+IveQuBi/Kn3SaqFZ1gaiQrE3yPlMhCMA=";
|
||||
};
|
||||
};
|
||||
hetzner-vpn2 = {
|
||||
system = "aarch64-linux";
|
||||
};
|
||||
hyperforge = {
|
||||
system = "aarch64-linux";
|
||||
};
|
||||
muede-lpt2 = {
|
||||
system = "x86_64-linux";
|
||||
isDesktop = true;
|
||||
home-manager-users = {
|
||||
inherit (self.homeConfigurations) muede;
|
||||
};
|
||||
distributedBuilds = {
|
||||
isBuilder = true;
|
||||
speedFactor = 2;
|
||||
hostPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHGKoZ68wwyVRmPB0SkvpJUyUMDWeFbC5Je9zukyEOh7";
|
||||
clientPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKAbojdhb3PfazSRmudvo381Y+zUFVLMa7AbWbfK/Zp2 muede-lpt2-nix-builds";
|
||||
storeSigningPublicKey = "muede-lpt2:3csut7FW6oZK/ztRLBRC80LSBfFE3qzl+aIYgOixB6U=";
|
||||
};
|
||||
};
|
||||
muede-pc2 = {
|
||||
system = "x86_64-linux";
|
||||
isDesktop = true;
|
||||
home-manager-users = {
|
||||
inherit (self.homeConfigurations) muede;
|
||||
};
|
||||
distributedBuilds = {
|
||||
isBuilder = true;
|
||||
speedFactor = 4;
|
||||
hostPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKEQQS5XNoj62Oj85xQfIuLORwoBRwfqjvfBHHsiI+RH";
|
||||
clientPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHmnyhP6L+kGHV15cb/d31AQr50wSEaQhkUBwy2+OEKk muede-pc2-nix-builds";
|
||||
storeSigningPublicKey = "muede-pc2:fqQO0E0y65MjUWlQnrgWt5ZsmQKlKCv4jls3CmUXDEQ=";
|
||||
};
|
||||
};
|
||||
ronja-pc = {
|
||||
system = "x86_64-linux";
|
||||
isDesktop = true;
|
||||
home-manager-users = {
|
||||
inherit (self.homeConfigurations) ronja;
|
||||
};
|
||||
};
|
||||
# keep-sorted end
|
||||
}
|
||||
204
docs/agent-hierarchy.md
Normal file
204
docs/agent-hierarchy.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# Agent hierarchy & privileges
|
||||
|
||||
Design + audit doc for milestone #6 (the
|
||||
[issue](http://localhost:3000/hyperhive/hyperhive/issues/361) tree).
|
||||
The implementation lands in pieces; this doc tracks what's done, what's
|
||||
planned, and what currently special-cases the manager.
|
||||
|
||||
## Current state (as of this PR)
|
||||
|
||||
Topology lives in the hive-c0re-owned **meta repo**, alongside
|
||||
`flake.nix`, at `/var/lib/hyperhive/meta/topology.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"manager": null,
|
||||
"alice": "manager",
|
||||
"bob": "alice"
|
||||
}
|
||||
```
|
||||
|
||||
`null` = root-level agent. Today only the manager qualifies by default.
|
||||
Other agents land under `"manager"` on first sync. Re-parenting is
|
||||
operator-driven (#486 / #487):
|
||||
|
||||
- CLI: `hive-c0re set-parent <child> --parent <new>` (or `--root` to
|
||||
promote). Exactly one of `--parent` / `--root` is required.
|
||||
- Dashboard: `POST /api/topology/set-parent` (form fields `child`,
|
||||
optional `new_parent` — absent / empty ⇒ promote to root).
|
||||
- Wire: `HostRequest::SetParent { child, new_parent: Option<String> }`.
|
||||
|
||||
All three converge on `topology::set_parent`, which delegates the
|
||||
validation rules to a pure `apply_set_parent` helper. Refuses:
|
||||
- reparenting the manager (structurally root),
|
||||
- unknown `child` / `new_parent` (typo guard),
|
||||
- self-parenting,
|
||||
- cycles (32-hop ancestor walk, mirroring `is_descendant_of`).
|
||||
|
||||
Idempotent no-op fast path skips the disk write when the parent is
|
||||
already what's requested. After a successful write the surfaces call
|
||||
`Coordinator::rescan_containers_and_emit` so connected dashboard
|
||||
viewers see the tree repaint without polling
|
||||
(`ContainerView.parent` is sourced from `topology.json`).
|
||||
|
||||
**Today's caveat (#361 follow-up):** the move is purely a JSON edit.
|
||||
Only the top-level manager (`hm1nd`) gets `/var/lib/hyperhive/agents`
|
||||
bind-mounted at `/agents` in its container, so sub-agents don't yet
|
||||
see their would-be children's state. Once sub-manager bind mounts
|
||||
land alongside #361 enforcement, `set_parent` grows a companion
|
||||
umount-old / mount-new / restart-cascade step (tracked via the
|
||||
cross-ref comment on #361).
|
||||
|
||||
### Why meta, not per-agent `agent.nix`
|
||||
|
||||
An agent shouldn't be able to claim a parent without that parent's
|
||||
consent, and operator-driven re-parenting shouldn't require touching
|
||||
the moved agent's config. Topology IS a system-level concern; meta is
|
||||
where system-level facts live.
|
||||
|
||||
### Flow
|
||||
|
||||
1. **Read**: `topology::read()` parses `topology.json` into a
|
||||
`BTreeMap<String, Option<String>>`. Missing / unparsable file →
|
||||
empty map → every agent treated as root (safe degradation for
|
||||
fresh installs that haven't run `meta::sync_agents` yet).
|
||||
2. **Reconcile**: `meta::sync_agents` calls `topology::reconcile`
|
||||
alongside its `flake.nix` regeneration. New agents land at their
|
||||
default position (manager as parent, manager itself as root);
|
||||
removed agents drop. Existing entries are preserved as-is so
|
||||
operator overrides stick across regenerations.
|
||||
3. **Inject**: `meta::render_flake` looks up each agent's parent and
|
||||
passes it to `mkAgent`. When non-null, the mkAgent body sets
|
||||
`HIVE_PARENT = parent` in the agent's systemd service environment
|
||||
so the harness / claude prompts can see it.
|
||||
4. **Surface**: `container_view::build_all` reads `topology.json` and
|
||||
populates `ContainerView.parent: Option<String>` on every rescan.
|
||||
The dashboard renders the field as a tree (#363 follow-up).
|
||||
|
||||
## Target topology semantics
|
||||
|
||||
Once enforcement lands the rules collapse into:
|
||||
|
||||
| operation | who can do it |
|
||||
|---|---|
|
||||
| `kill` / `start` / `restart` / `update` (any descendant) | any ancestor |
|
||||
| `request_init_config` (spawn a new child) | any agent, child added under self |
|
||||
| `request_apply_commit` (any descendant's config) | any ancestor |
|
||||
| `get_logs` (any descendant) | any ancestor |
|
||||
| moderate questions / reminders (cancel any open thread of a descendant) | any ancestor |
|
||||
| `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else |
|
||||
| `request_update_meta_inputs` (bump meta lock) | root agents only (today: just `manager`) |
|
||||
|
||||
"Ancestor" walks `ContainerView.parent` chains; cycles are guarded by a
|
||||
visited-set at dispatch time (a malformed topology.json can't lock the
|
||||
dispatcher into a loop).
|
||||
|
||||
## Current manager special-casings — the audit
|
||||
|
||||
What currently makes the manager different from every other agent, and
|
||||
which axis the post-milestone version reads each special-case along:
|
||||
|
||||
### A — naming + bootstrap
|
||||
|
||||
- `MANAGER_AGENT = "manager"` (broker recipient name) and
|
||||
`MANAGER_NAME = "hm1nd"` (container name). ~28 grep hits across
|
||||
`hive-c0re/src/`. **Just a name** — the rename plan is `manager` →
|
||||
`root`, executed via the one-shot migration script in
|
||||
`migrate.rs` (idempotent, marker-guarded).
|
||||
- `auto_update::ensure_manager` runs at hive-c0re boot and spawns
|
||||
`hm1nd` if missing. Becomes "ensure the root agent exists" once any
|
||||
agent can be at the root. **Topology**: root has no parent, so
|
||||
hive-c0re itself owns its lifecycle (no parent to delegate to).
|
||||
|
||||
### B — wire-protocol privileges
|
||||
|
||||
The `ManagerRequest::*` variants in `hive-sh4re/src/lib.rs` are
|
||||
operations the manager flavour socket can make that sub-agent sockets
|
||||
can't:
|
||||
|
||||
| variant | semantic | post-milestone |
|
||||
|---|---|---|
|
||||
| `RequestInitConfig` | seed an agent's proposed config repo | **topology** — descendants only |
|
||||
| `RequestApplyCommit` | submit a commit sha for operator approval | **topology** — descendants only |
|
||||
| `Kill` / `Start` / `Restart` / `Update` | container lifecycle on an existing agent | **topology** — descendants only |
|
||||
| `RequestUpdateMetaInputs` | bump meta `flake.lock` | **per-agent cap** (root-only today; a future "let coder bump its own input" might grant it) |
|
||||
| `GetLogs` | journalctl scrape of a sub-agent | **topology** — descendants only |
|
||||
| `Wake` | inject a `from: <X>` message into self's inbox | **not really privileged** — the wire surface exists because daemon co-processes (e.g. `forge_notify`) need it. Sub-agents have the same via their own socket. |
|
||||
|
||||
### C — storage / mounts (`hive-c0re::lifecycle`)
|
||||
|
||||
The manager container's nspawn bind set:
|
||||
|
||||
- `HOST_AGENTS_ROOT (/var/lib/hyperhive/agents) → /agents` RW — so the
|
||||
manager can edit any agent's proposed config repo
|
||||
- `HOST_APPLIED_ROOT (/var/lib/hyperhive/applied) → /applied` RO — so
|
||||
the manager can diff against what's deployed
|
||||
- `HOST_META_ROOT (/var/lib/hyperhive/meta) → /meta` RO — so the
|
||||
manager can read the system-wide deploy log
|
||||
|
||||
Tree-shape version:
|
||||
- Each agent gets RW to `/agents/<descendant>/` for every descendant in
|
||||
its subtree. The root agent (today: manager) gets RW to the full
|
||||
forest as a special case of "the root has every other agent as a
|
||||
descendant".
|
||||
- RO `/meta` access if the agent holds a "meta read" cap.
|
||||
- `request_update_meta_inputs` is the only path that actually writes
|
||||
`flake.lock`, gated by the cap; everyone else stays RO.
|
||||
|
||||
### D — drop legacy `/state` for manager
|
||||
|
||||
`lifecycle.rs::notes_mount` currently ternary's `/state` for the
|
||||
manager and `/agents/<name>/state` for everyone else (because the
|
||||
manager pre-dates the per-agent state-dir layout). Milestone bullet:
|
||||
unify on `/agents/<name>/state` for everyone. One-time `mv` of
|
||||
`/var/lib/hyperhive/manager/state` → `/var/lib/hyperhive/agents/manager/state`
|
||||
in `migrate.rs` (idempotent, marker-guarded).
|
||||
|
||||
### E — prompt + tools
|
||||
|
||||
- `prompts/manager.md` vs `prompts/agent.md` — two separate system
|
||||
prompts. **Per-agent cap list** of what the agent can do, rendered
|
||||
into a single parametrised prompt at boot.
|
||||
- `mcp.rs::Flavor::{Agent, Manager}` controls which MCP tools claude
|
||||
sees. Already structured this way internally — the per-flavour
|
||||
allow-list becomes a per-cap-set lookup.
|
||||
|
||||
### F — drive-by checks across c0re
|
||||
|
||||
(`grep -n MANAGER_AGENT` produced ~28 hits)
|
||||
|
||||
- `loose_ends.rs`: manager sees hive-wide loose-ends, sub-agents only
|
||||
their own. **Topology** — every agent sees its own + its
|
||||
descendants'.
|
||||
- `operator_questions.rs` + `broker.rs`: "manager can cancel any
|
||||
question" override on the owner check. **Topology** — agents can
|
||||
moderate threads of their descendants. (per mara's
|
||||
https://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3344)
|
||||
- `reminder_scheduler.rs`: same override pattern for reminder cancel.
|
||||
**Topology** — descendants only.
|
||||
- `actions.rs`: `destroy` refuses to act on `MANAGER_NAME` (no
|
||||
foot-shooting). **Topology** — agents can destroy descendants but
|
||||
never themselves or ancestors.
|
||||
- `crash_watch.rs`: skips `ContainerCrash` for the manager (it
|
||||
auto-restarts via systemd). **Topology** — the root container has
|
||||
different recovery semantics, every other agent falls into the same
|
||||
watch loop.
|
||||
|
||||
### G — sub-agents inside the same container
|
||||
|
||||
Future work mentioned in #361: when enabled for an agent, it can spawn
|
||||
temporary "sub-agents" that run inside its own container. Lighter than
|
||||
a full nspawn agent. Open questions, not yet wired:
|
||||
|
||||
- Inherit caps from parent, or take an explicit narrower set?
|
||||
- Survive container restart, or always ephemeral?
|
||||
- Inbox: separate from parent, or shared?
|
||||
- Filesystem: share parent's `/state` RW, or a sub-dir?
|
||||
- Identity: distinct broker recipient name, or address the parent?
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Milestone: [#361 "Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361)
|
||||
- Dashboard render: [#363 "show agent topology in container list"](http://localhost:3000/hyperhive/hyperhive/issues/363)
|
||||
- Audit table source: [comment 3335 on #361](http://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3335)
|
||||
- Operator/agent trust boundary (orthogonal axis): [`boundary.md`](boundary.md)
|
||||
471
docs/approvals.md
Normal file
471
docs/approvals.md
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
# Approvals + manager + helper events
|
||||
|
||||
The approval queue is hyperhive's pivot: nothing that changes the
|
||||
shape of an agent (its config, whether it exists) happens without an
|
||||
operator click. The manager (`hm1nd`) is the policy gate in front of
|
||||
that queue; helper events are how it stays informed about what
|
||||
happens after a decision lands.
|
||||
|
||||
## End-to-end approval flow
|
||||
|
||||
1. Manager edits files under `/agents/<name>/config/` (any tracked
|
||||
path, but `agent.nix` is the contract entry point) and commits
|
||||
with its own git identity.
|
||||
2. Manager submits the commit sha via `request_apply_commit(agent,
|
||||
commit_ref)`. `commit_ref` must be a commit **sha** (7-40 hex
|
||||
chars, short or full) — a branch or tag name is rejected so the
|
||||
approval pins an immutable commit.
|
||||
3. **hive-c0re immediately fetches that commit from the proposed
|
||||
repo into the applied repo and tags it `proposal/<id>`.** It
|
||||
resolves the sha locally against the proposed repo, fetches all
|
||||
of proposed's heads into applied's object db, then tags the
|
||||
resolved commit — `git fetch <remote> <sha>:<dst>` can't fetch
|
||||
by a bare sha (the left side of a refspec is a remote *ref
|
||||
name*), so the resolution happens on hive-c0re's side. The
|
||||
approval row stores both the manager-supplied sha and the
|
||||
canonical hive-c0re-vouched sha. From here on the proposed
|
||||
repo is irrelevant for this approval — the manager can amend,
|
||||
force-push, or `rm -rf` the proposed repo and the queued
|
||||
approval still points at an immutable git object inside
|
||||
applied.
|
||||
3a. **Flake validation (ApplyCommit only):** after the proposal tag
|
||||
is planted, hive-c0re reads `proposal/<id>:flake.lock` and
|
||||
runs two checks (closes #317). If either check fails, no
|
||||
pending approval is created for the operator — the row is
|
||||
marked failed and surfaces on the dashboard with the
|
||||
validation message:
|
||||
- **Stale lock** — materialises the commit in a temp worktree,
|
||||
runs `nix flake lock` (no `--update-input` flags, so it only
|
||||
fills missing entries), and rejects if the committed
|
||||
`flake.lock` differs from the result. Triggered when the
|
||||
manager added or removed `inputs` in `flake.nix` without
|
||||
re-running `nix flake lock`. Fix: run `nix flake lock` in
|
||||
the config repo, commit, and re-submit.
|
||||
- **Duplicate inputs** — groups lock nodes by their canonical
|
||||
`original` field; rejects if two or more nodes share the same
|
||||
source. This usually means an input is missing
|
||||
`inputs.<x>.inputs.nixpkgs.follows = "nixpkgs"`. Fix: add
|
||||
the `follows` directive, re-lock, and re-submit.
|
||||
Both checks only flag *new* violations — agents whose lock
|
||||
already carried duplicates before this check was added are
|
||||
unaffected until a coordinated config-change pass via manager.
|
||||
4. Operator sees the proposal as a card on the dashboard — a
|
||||
full multi-file diff, toggleable between three bases (vs the
|
||||
running tree / vs the last approved proposal / vs the
|
||||
previous queued proposal) — and clicks ◆ APPR0VE (or
|
||||
`hive-c0re approve <id>` on the CLI).
|
||||
5. hive-c0re moves the working tree to `proposal/<id>` and runs
|
||||
the build under a sequence of tags (see below). On success,
|
||||
`applied/main` fast-forwards to the proposal commit. On
|
||||
failure, main stays put and the working tree resets back to
|
||||
the previous deployed commit.
|
||||
6. `HelperEvent::ApprovalResolved` (and `Rebuilt` for the
|
||||
ApplyCommit kind) land in the manager's inbox, carrying both
|
||||
the canonical sha and the terminal tag.
|
||||
|
||||
### Withdrawing a pending approval
|
||||
|
||||
The manager can call `cancel_loose_end(kind: "approval", id)` to
|
||||
withdraw an approval that hasn't been acted on yet (closes #250).
|
||||
The row transitions to `ApprovalStatus::Cancelled` (distinct from
|
||||
`Denied`/`Failed`), the dashboard pulls the card out of the
|
||||
pending pane, and `ApprovalResolved { status: "cancelled" }` fires
|
||||
on the manager + dashboard channels. Approvals that have already
|
||||
been approved/denied/failed return an error — the resolution is
|
||||
final once the operator (or a lifecycle failure) acted on the row.
|
||||
|
||||
Sub-agent surface refuses the `approval` kind with a clear error:
|
||||
sub-agents don't submit approvals, so they have nothing of their
|
||||
own to withdraw. Manager-only.
|
||||
|
||||
`InitConfig` approvals are the first step in a two-step spawn
|
||||
flow. On approve, hive-c0re seeds the proposed config repo with
|
||||
a default `agent.nix` template and sends the manager
|
||||
`HelperEvent::ConfigReady { agent }`. The manager then reviews,
|
||||
edits, and commits the template before calling `request_apply_commit`
|
||||
to proceed to an `ApplyCommit` approval. The first `ApplyCommit`
|
||||
creates the container; subsequent ones rebuild it with new config.
|
||||
This gives the manager (and operator) an explicit review gate on the
|
||||
initial configuration before any container is created.
|
||||
|
||||
## Meta flake
|
||||
|
||||
The hive-c0re-owned repo at `/var/lib/hyperhive/meta/`
|
||||
declares one flake input per agent (`agent-<n>.url =
|
||||
"git+file:///var/lib/hyperhive/applied/<n>"`) and one
|
||||
`nixosConfigurations.<n>` output per agent. Each output wraps
|
||||
`inputs.agent-<n>.nixosModules.default` with the identity +
|
||||
`HIVE_PORT` / `HIVE_LABEL` / `HIVE_DASHBOARD_PORT` injection
|
||||
module that `setup_applied` used to generate inline.
|
||||
Containers run against `--flake /var/lib/hyperhive/meta#<n>`.
|
||||
|
||||
Per-deploy lock flow (two-phase, owned by
|
||||
`actions::run_apply_commit` → `meta::{prepare,finalize,abort}
|
||||
_deploy`):
|
||||
|
||||
1. `meta::prepare_deploy(name)` runs
|
||||
`nix flake lock --update-input agent-<n>` without
|
||||
committing. Working tree of meta now points the input at
|
||||
`applied/<n>/main` (which `run_apply_commit` already
|
||||
fast-forwarded to `proposal/<id>`).
|
||||
2. `lifecycle::rebuild_no_meta` runs
|
||||
`nixos-container update <c> --flake meta#<name>`. Nix
|
||||
evaluates against the staged lock.
|
||||
3. On success — `meta::finalize_deploy(name, sha, "deployed/
|
||||
<id>")` stages `flake.lock` and commits with
|
||||
`deploy <n> deployed/<id> <sha12>`. Meta's git log gains
|
||||
one entry per successful deploy.
|
||||
4. On failure — `meta::abort_deploy()` runs
|
||||
`git restore flake.lock` so the meta history shows only
|
||||
successes; the failure stays as an annotated `failed/<id>`
|
||||
tag in `applied/<n>`.
|
||||
|
||||
Single-phase variants exist for paths without
|
||||
rollback semantics: `meta::lock_update_for_rebuild(name)` for
|
||||
the manual `↻ R3BU1LD` button (commits if the lock changed)
|
||||
and `meta::lock_update_hyperhive()` for the
|
||||
auto-update flake-rev bump (one shot before per-agent
|
||||
rebuilds, commits if the lock changed).
|
||||
|
||||
`meta::sync_agents(hyperhive_flake, dashboard_port, &agents)`
|
||||
is the idempotent reconciler called by `spawn`, `destroy`,
|
||||
`rebuild`, and the startup migration. Renders `flake.nix`
|
||||
from the agent list; if it differs from disk, runs
|
||||
`nix flake lock` + commits as `regenerate meta flake` (or
|
||||
`seed meta from N agent(s)` on the very first call).
|
||||
|
||||
The manager has `/meta` RO-bound inside its container:
|
||||
`git -C /meta log --oneline` is the swarm-wide deploy log,
|
||||
`cat /meta/flake.lock | jq '.nodes["agent-<n>"].locked'`
|
||||
resolves which sha each agent is pinned at right now.
|
||||
Dashboard surfaces the same info as a `deployed:<sha12>` chip
|
||||
per container row.
|
||||
|
||||
## Two repos per agent
|
||||
|
||||
```
|
||||
/var/lib/hyperhive/agents/<name>/config/ proposed — manager RW
|
||||
└── <anything> # any files the manager
|
||||
# wants in the commit.
|
||||
# agent.nix is the
|
||||
# convention entry
|
||||
# point; flake.nix is
|
||||
# tracked boilerplate
|
||||
# (manager doesn't edit
|
||||
# it).
|
||||
|
||||
/var/lib/hyperhive/applied/<name>/ applied — core-only
|
||||
├── .git/ # tag-rich history
|
||||
├── flake.nix # tracked, fixed
|
||||
│ # boilerplate exporting
|
||||
│ # nixosModules.default
|
||||
├── agent.nix # working tree of main
|
||||
└── <other manager files> # also tracked
|
||||
|
||||
/var/lib/hyperhive/meta/ swarm-wide flake — core
|
||||
├── .git/ # one commit per successful
|
||||
│ # deploy
|
||||
├── flake.nix # generated from agent set
|
||||
└── flake.lock # pins each agent's sha
|
||||
```
|
||||
|
||||
Why two physical repos: the manager's `/agents/<n>/config/` is
|
||||
RW — a buggy or hostile agent can `git clean -fdx` its own
|
||||
proposed tree. The applied repo is never bind-mounted (except
|
||||
the read-only `.git` exposure described below) so a destructive
|
||||
move inside the container cannot reach it.
|
||||
|
||||
The container's `--flake` ref is `/var/lib/hyperhive/meta#<name>`
|
||||
(see "Meta flake" above). The agent's own `applied/<n>/flake.nix`
|
||||
is a fixed boilerplate that exports `nixosModules.default =
|
||||
import ./agent.nix`; the meta flake imports that module and
|
||||
wraps it with identity + `HIVE_PORT` / `HIVE_LABEL` /
|
||||
`HIVE_DASHBOARD_PORT`.
|
||||
|
||||
### Tag state machine
|
||||
|
||||
Every approval id walks through a fixed set of tags on the
|
||||
underlying commit inside the applied repo:
|
||||
|
||||
| Tag | When | Annotated? |
|
||||
|---|---|---|
|
||||
| `proposal/<id>` | request_apply_commit, after fetch | no |
|
||||
| `approved/<id>` | operator approve | no |
|
||||
| `building/<id>` | rebuild started | no |
|
||||
| `deployed/<id>` | rebuild succeeded — `main` ff's here | no |
|
||||
| `failed/<id>` | rebuild failed | yes (body = error) |
|
||||
| `denied/<id>` | operator deny | yes (body = operator note) |
|
||||
|
||||
`applied/main` is always the latest `deployed/*`. `denied/` and
|
||||
`failed/` are terminal; the manager submits a new commit + new
|
||||
approval id to retry. Because tags are first-class git objects,
|
||||
rejected and failed trees stay browsable forever — `git log
|
||||
--tags` in the applied repo is the audit trail.
|
||||
|
||||
### Dispatch via `rebuild_queue` (#441)
|
||||
|
||||
Long-running approval work — `ApplyCommit`, `UpdateMetaInputs`,
|
||||
`Spawn` — no longer runs inline inside `actions::approve`. Instead
|
||||
the approval handler enqueues a `QueueEntry` into the global
|
||||
`rebuild_queue`:
|
||||
|
||||
| `ApprovalKind` | `QueueKind` queued | `QueueSource` |
|
||||
|---|---|---|
|
||||
| `ApplyCommit` | `Rebuild` | `Approval` |
|
||||
| `UpdateMetaInputs` | `MetaUpdate` | `Approval` |
|
||||
| `Spawn` | `Spawn` | `Approval` |
|
||||
| `InitConfig` | — runs inline (sub-second git seed) | — |
|
||||
| `SchedulePrompt` | — runs inline (single sqlite insert) | — |
|
||||
|
||||
Each queue entry carries the originating `approval_id` so the
|
||||
worker can re-fetch the approval row when it dispatches, run the
|
||||
kind-specific pipeline (`run_approval_apply_commit` /
|
||||
`run_approval_update_meta_inputs` / `run_approval_spawn`), and
|
||||
fire the matching `HelperEvent::*` on completion via
|
||||
`finish_approval`.
|
||||
|
||||
Two visible consequences:
|
||||
|
||||
- **Operator dashboard**: after clicking APPR0VE the work-in-progress
|
||||
shows up on the *rebuild queue* card (`POST /api/state.rebuild_queue`
|
||||
+ live `rebuild_queue_changed` events), not on the approvals panel
|
||||
(which already moved the row to "approved"). A long meta-update
|
||||
cascade renders as a parent entry with one child per per-agent
|
||||
rebuild — see `docs/web-ui.md` for the layout.
|
||||
- **Cancellation**: the dashboard's *× cancel* button on a `Queued`
|
||||
entry calls `POST /api/rebuild-queue/{id}/cancel`, which flips the
|
||||
entry to `Cancelled` before the worker dispatches it. Returns
|
||||
`{"cancelled": true}` on success, `{"cancelled": false}` if the
|
||||
entry already left `Queued` (running / done / failed) — terminal
|
||||
states can't be retroactively rewritten.
|
||||
|
||||
`QueueSource::Approval` carries the `approval_id` so a tail-end
|
||||
build failure surfaces back as a failed approval row, not just a
|
||||
silent queue entry. `QueueSource::Manual` (dashboard ↻ R3BU1LD)
|
||||
and `QueueSource::AutoUpdate` (boot-time sweep) use the same
|
||||
queue but skip the approval row plumbing.
|
||||
|
||||
### Forge mirror
|
||||
|
||||
When the bundled `hive-forge` container is running — on by
|
||||
default, `hyperhive.forge.enable` — hive-c0re mirrors every
|
||||
agent's applied repo into a private `agent-configs` Forgejo
|
||||
org. `forge::push_config(<name>)` pushes `applied/main` plus
|
||||
every tag to `agent-configs/<name>` after each ref mutation:
|
||||
the spawn that seeds `deployed/0`, every `request_apply_commit`
|
||||
(which plants `proposal/<id>`), every approve / deny, and a
|
||||
sweep at startup. Pushes are best-effort — a missing or stopped
|
||||
forge never blocks a deploy.
|
||||
|
||||
The org is private and agents are not members, so only the
|
||||
`core` user (a Forgejo site admin) can read it: an agent can't
|
||||
reach another agent's config — or even its own — through the
|
||||
forge. The tokenised push URL is passed inline to `git push`,
|
||||
never written into `applied/<n>/.git/config`; that repo is
|
||||
RO-bind-mounted into the manager, and a stored token would leak
|
||||
core's admin credential to an agent.
|
||||
|
||||
The dashboard deep-links into this org — a `config repo` link
|
||||
per container row and a `commit on forge` link per approval
|
||||
card. See `docs/web-ui.md`.
|
||||
|
||||
### Manager view of applied + meta
|
||||
|
||||
The manager container gets three host-side bind mounts via
|
||||
`set_nspawn_flags`:
|
||||
|
||||
- `/var/lib/hyperhive/agents/` → `/agents/` (RW) — proposed
|
||||
repos. Manager edits + commits per-agent config here.
|
||||
- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every
|
||||
agent's authoritative applied repo, including `.git`.
|
||||
- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the swarm-wide
|
||||
deploy flake.
|
||||
|
||||
Each proposed repo (`/agents/<n>/config/`) is pre-configured
|
||||
with `applied` as a git remote pointing at
|
||||
`/applied/<n>/.git`. Useful incantations from inside the
|
||||
manager:
|
||||
|
||||
```sh
|
||||
git -C /agents/<n>/config fetch applied
|
||||
git -C /agents/<n>/config log applied/main --oneline
|
||||
git -C /agents/<n>/config show applied/refs/tags/deployed/<id>
|
||||
git -C /agents/<n>/config show applied/refs/tags/failed/<id> # body = build error
|
||||
git -C /agents/<n>/config show applied/refs/tags/denied/<id> # body = operator note
|
||||
git -C /agents/<n>/config rebase applied/main # base in-flight work on what's deployed
|
||||
|
||||
git -C /meta log --oneline # swarm-wide deploy history
|
||||
cat /meta/flake.lock | jq '.nodes | with_entries(select(.key | startswith("agent-")))'
|
||||
```
|
||||
|
||||
The RO binds block push at the kernel level, so the manager
|
||||
can only fetch / read — git plumbing inside the container
|
||||
cannot corrupt either authoritative repo.
|
||||
|
||||
## Migration from the pre-tag / pre-meta schemes
|
||||
|
||||
Both overhauls (tag-driven flow + meta flake) ship in-place
|
||||
migrations that run on every hive-c0re startup. Idempotent;
|
||||
each phase is a no-op once already applied. Behaviour:
|
||||
|
||||
- Tag-driven phase: assumes the operator ran the one-shot
|
||||
`git tag deployed/0 main` script (see commit history /
|
||||
earlier docs revisions) once per agent. Tagging is
|
||||
non-destructive: it doesn't touch live containers, state
|
||||
dirs, or claude creds.
|
||||
- Meta-flake phase: rewrites each `applied/<n>/flake.nix` to
|
||||
the module-only boilerplate, wires the `applied` remote in
|
||||
each proposed repo, bootstraps the meta repo from the
|
||||
current agent list, and `nixos-container update`s every
|
||||
container at `meta#<n>`. The expensive last step is
|
||||
guarded by `/var/lib/hyperhive/.meta-migration-done` so
|
||||
it only runs once across hive-c0re restarts. Set
|
||||
`HIVE_SKIP_META_MIGRATION=1` on the service to defer.
|
||||
|
||||
No state loss in either migration. claude creds, /state/
|
||||
notes, the events DB, proposed history, and applied history
|
||||
all survive. The manager keeps its session; sub-agents stay
|
||||
logged in.
|
||||
|
||||
## Manager (`hm1nd`) is hive-c0re-managed
|
||||
|
||||
The manager container runs through the **same lifecycle as
|
||||
sub-agents**. On `hive-c0re serve` startup, if `hm1nd` is missing,
|
||||
hive-c0re creates it. The manager's flake lives at
|
||||
`/var/lib/hyperhive/applied/hm1nd/`; its proposed config at
|
||||
`/var/lib/hyperhive/agents/hm1nd/config/`. Manager can edit its own
|
||||
`agent.nix` (visible inside the container at `/agents/hm1nd/config/`)
|
||||
and submit `request_apply_commit("hm1nd", <sha>)` for operator
|
||||
approval.
|
||||
|
||||
Differences from sub-agents:
|
||||
|
||||
- `flake.nix` extends `hyperhive.nixosConfigurations.manager`
|
||||
(vs `agent-base`).
|
||||
- Container name is `hm1nd` (no `h-` prefix).
|
||||
- Fixed web UI port (`MANAGER_PORT = 8000`).
|
||||
- `set_nspawn_flags` adds two extra binds: `/var/lib/hyperhive/agents`
|
||||
→ `/agents` (RW) so the manager can edit per-agent proposed repos,
|
||||
and `/var/lib/hyperhive/applied` → `/applied` (RO) so the manager
|
||||
can `git fetch` deployed/failed/denied tags from any agent's
|
||||
authoritative applied repo (see "Manager view of applied" below).
|
||||
- First-deploy spawn bypasses the approval queue (manager is
|
||||
required infrastructure).
|
||||
- Per-agent socket lives at `/run/hyperhive/manager/`, owned by
|
||||
`manager_server::start`.
|
||||
|
||||
**Migration note** (for older hosts): drop any `containers.hm1nd =
|
||||
{ ... }` block from your host NixOS config. hyperhive creates and
|
||||
updates the manager itself.
|
||||
|
||||
## Manager policy
|
||||
|
||||
From `hive-ag3nt/prompts/manager.md`: the manager does NOT
|
||||
rubber-stamp sub-agent config requests. It verifies (role match,
|
||||
package legitimacy, cheaper alternative, blast radius) before
|
||||
committing and calling `request_apply_commit`.
|
||||
|
||||
For ambiguous cases or anything that needs human signal, the
|
||||
manager calls `ask(question, options?, multi?, ttl_seconds?, to?)` —
|
||||
queues the question and returns the id immediately. When `to` is
|
||||
omitted (or `"operator"`) the question shows up on the dashboard;
|
||||
when `to` is a sub-agent's name, the recipient receives a
|
||||
`HelperEvent::QuestionAsked` and answers via their own `answer`
|
||||
tool. Either way the answer arrives back as
|
||||
`HelperEvent::QuestionAnswered { id, question, answer, answerer }`
|
||||
in the asker's inbox. Storage is `hive-c0re::operator_questions`
|
||||
(sqlite) — same table, with a nullable `target` column
|
||||
(NULL = operator). Dispatch goes through
|
||||
`hive-c0re/src/questions.rs::{handle_ask, handle_answer}` so both
|
||||
the agent + manager surfaces stay aligned. The answer flow is:
|
||||
|
||||
```
|
||||
POST /answer-question/{id} agent: Answer { id, answer }
|
||||
→ OperatorQuestions::answer(_, _, "operator") → questions::handle_answer
|
||||
→ notify_agent(asker, QuestionAnswered { → OperatorQuestions::answer(_, _, agent)
|
||||
answerer: "operator", ... }) → notify_agent(asker, QuestionAnswered {
|
||||
answerer: agent, ... })
|
||||
```
|
||||
|
||||
Two more paths resolve a pending question with a sentinel answer:
|
||||
|
||||
- `POST /cancel-question/{id}` (✗ CANC3L button on the dashboard)
|
||||
resolves with `[cancelled]`. The manager sees a terminal state
|
||||
and can fall back.
|
||||
- `ttl_seconds` deadline: a tokio watchdog spawned at submit time
|
||||
fires `answer(id, "[expired]")` once the ttl runs out. Already-
|
||||
resolved races no-op. The dashboard surfaces a `⏳ MM:SS` chip
|
||||
on each pending question with a deadline.
|
||||
|
||||
## Helper events to the manager
|
||||
|
||||
`Coordinator::notify_manager(&HelperEvent)` enqueues an inbox
|
||||
message from sender `system` with the event JSON in the body. The
|
||||
manager harness no longer short-circuits these — they drive a
|
||||
regular claude turn so the manager can react. Variants
|
||||
(`hive_sh4re::HelperEvent`):
|
||||
|
||||
- `ApprovalResolved { id, agent, commit_ref, status, note }` —
|
||||
fired by `actions::approve` + `actions::deny` whenever an
|
||||
approval transitions to its terminal state.
|
||||
- `Spawned { agent, ok, note }` — `actions::approve` (first-time
|
||||
ApplyCommit-kind) + admin `HostRequest::Spawn` (deprecated).
|
||||
- `Rebuilt { agent, ok, note }` — `auto_update::rebuild_agent`
|
||||
(covers startup scan + manual `/rebuild` from dashboard) +
|
||||
`actions::approve` (ApplyCommit).
|
||||
- `Killed { agent }` — admin `HostRequest::Kill` + dashboard
|
||||
`/kill` + manager `Kill` MCP tool.
|
||||
- `Destroyed { agent }` — `actions::destroy`.
|
||||
- `ContainerCrash { agent, note }` — `crash_watch`: a previously-
|
||||
running container went away with no operator-initiated transient
|
||||
state (Stopping / Restarting / Destroying / Rebuilding) AND no
|
||||
such transient was cleared in the last 30s (`RECENT_TRANSIENT_GRACE`
|
||||
tombstone, three `POLL_INTERVAL`s — closes the race where a
|
||||
lifecycle op finishes between two crash-watch polls and the
|
||||
container shows briefly as "stopped without transient" before the
|
||||
next start, #425). Manager can `start` it again or escalate.
|
||||
- `NeedsLogin { agent }` — sub-agent has no claude session yet.
|
||||
Manager can't act directly (interactive OAuth); typically flags
|
||||
the operator.
|
||||
- `LoggedIn { agent }` — sub-agent just completed login. Manager
|
||||
often greets the agent on this event.
|
||||
- `ConfigReady { agent }` — a new agent's proposed config repo was
|
||||
just seeded (post-`InitConfig` approval). The manager can now
|
||||
edit `/agents/<agent>/config/agent.nix`, commit the changes,
|
||||
and submit `request_apply_commit` with the commit sha to create
|
||||
the container (first ApplyCommit also triggers spawn bookkeeping).
|
||||
- `NeedsUpdate { agent }` — sub-agent's recorded flake rev is
|
||||
stale. Manager calls `update(name)` to rebuild — idempotent,
|
||||
no approval required.
|
||||
- `QuestionAnswered { id, question, answer, answerer }` —
|
||||
dashboard `/answer-question/{id}` (answerer = `"operator"`),
|
||||
peer `Answer` request (answerer = agent name), or ttl watchdog
|
||||
expiry (answerer = `"ttl-watchdog"`, answer = `"[expired]"`).
|
||||
- `QuestionAsked { id, asker, question, options, multi }` —
|
||||
fired when an agent calls `Ask { to: Some(<this-agent>), ... }`.
|
||||
The recipient responds via `Answer { id, answer }` and the
|
||||
asker sees the matching `QuestionAnswered`.
|
||||
|
||||
To add a new event: new `HelperEvent` variant + call sites + update
|
||||
`prompts/manager.md` so the manager knows the new shape.
|
||||
|
||||
## Auto-update on startup
|
||||
|
||||
`hive-c0re serve` runs `auto_update::run` in a background task right
|
||||
after opening the coordinator. It enumerates managed containers and
|
||||
rebuilds any whose recorded hyperhive rev differs from the current
|
||||
one — sub-agents and manager go through the same `lifecycle::rebuild`
|
||||
path.
|
||||
|
||||
"Rev" = canonical filesystem path of `cfg.hyperhiveFlake`. Marker
|
||||
file: `/var/lib/hyperhive/applied/.<name>.hyperhive-rev`. If the
|
||||
flake input has no canonical path (e.g. a `github:` URL),
|
||||
auto-update is a no-op — rebuild manually.
|
||||
|
||||
The dashboard surfaces pending updates per agent: a clickable
|
||||
"needs update ↻" badge appears whenever the marker differs from
|
||||
current rev. The badge POSTs `/rebuild/<name>`, calling the same
|
||||
`auto_update::rebuild_agent` path so manual triggers and the
|
||||
startup scan can't drift. When at least one container is stale, a
|
||||
top-level `↻ UPD4TE 4LL` button appears that loops over every
|
||||
stale container.
|
||||
59
docs/boundary.md
Normal file
59
docs/boundary.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# The operator/agent boundary
|
||||
|
||||
Design rationale for hyperhive's two-principal trust model. The
|
||||
*implementation* work — container network isolation, the unifying
|
||||
gateway, core-daemon privsep — is tracked as `area:ops` issues on
|
||||
the forge.
|
||||
|
||||
Today "the operator surface" and "the agent surface" are a
|
||||
*convention*, not a boundary — nothing stops a container from
|
||||
curling the core daemon on `localhost:<port>`, or another agent's
|
||||
web UI. Network isolation, the gateway, and privsep together turn
|
||||
that convention into an enforced boundary.
|
||||
|
||||
## Two principals, two paths
|
||||
|
||||
- **Operator** — reaches every UI (the dashboard + every
|
||||
per-agent page) through the gateway, on one origin.
|
||||
Operator-authority actions (approve / deny, answer-as-operator,
|
||||
lifecycle POSTs) are served by the core daemon and only
|
||||
reachable via the gateway.
|
||||
- **Agent** — speaks only for itself, only over its per-agent
|
||||
unix socket. The socket's identity *is* the agent (see
|
||||
`docs/conventions.md`, "identity = socket"). An agent must not
|
||||
be able to reach the core daemon's HTTP surface, another
|
||||
agent's socket, or another agent's web UI.
|
||||
|
||||
## Design rule
|
||||
|
||||
**Operator-authority actions never get a per-agent-socket entry
|
||||
point.** They live on the core backend.
|
||||
|
||||
Worked example — answering an operator-targeted question is a
|
||||
`POST /answer-question/{id}` on the core dashboard, *never* an
|
||||
`AgentRequest` variant. If it were a per-agent-socket request, an
|
||||
agent could `curl` its own socket and spoof an operator answer.
|
||||
The per-agent web UI POSTs cross-origin to the core for these
|
||||
(see the inline-answer feature — the loose-ends section on each
|
||||
agent page).
|
||||
|
||||
## Why network isolation is the load-bearing step
|
||||
|
||||
Containers currently share the host network namespace, so a
|
||||
container can reach `localhost:<core-port>`, the dashboard, and
|
||||
every other agent's web port. Until that changes, the
|
||||
operator/agent split is on the honour system — every boundary
|
||||
claim above is aspirational. Network isolation is what makes the
|
||||
boundary *real*; the gateway and privsep are ergonomics and
|
||||
defence-in-depth layered on top.
|
||||
|
||||
Suggested sequencing of the `area:ops` issues:
|
||||
|
||||
1. **Gateway** first — pure ergonomics win, unblocks same-origin
|
||||
(lets the cross-origin CORS shim on `/answer-question/{id}` go
|
||||
away), no behavioural risk.
|
||||
2. **Network isolation** next — the step that makes the boundary
|
||||
real. Everything before it is honour-system.
|
||||
3. **Privsep** last — defence in depth on the core process
|
||||
itself; valuable independent of the other two, but the
|
||||
biggest refactor.
|
||||
79
docs/conventions.md
Normal file
79
docs/conventions.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Conventions
|
||||
|
||||
Code-style and process expectations across the workspace. Most of these
|
||||
exist because something already went wrong without them.
|
||||
|
||||
## Naming
|
||||
|
||||
- Containers are length-bounded by `nixos-container` (≤ 11 chars).
|
||||
- Sub-agents are `h-<name>` with `<name>` ≤ 9 chars.
|
||||
- The manager is `hm1nd` (no `h-` prefix, fixed name).
|
||||
- `MAX_AGENT_NAME` in `lifecycle.rs` enforces the cap.
|
||||
- Per-agent web UI port = `WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE`
|
||||
(8100..8999); manager fixed at 8000; dashboard `cfg.dashboardPort`
|
||||
(default 7000).
|
||||
|
||||
## Identity = socket
|
||||
|
||||
There are no auth tokens on the per-agent unix sockets. The socket
|
||||
*path* identifies the principal; perms come from "who has the
|
||||
bind-mount." A sub-agent only sees its own `/run/hive/mcp.sock`; the
|
||||
manager has access to its privileged socket; hive-c0re owns the host
|
||||
admin socket.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
JSON line-delimited over unix sockets in both directions (host admin
|
||||
/ manager / agent). SSE streams (`/dashboard/stream` on hive-c0re,
|
||||
`/events/stream` on the per-agent web UIs) are `text/event-stream`;
|
||||
each frame carries a `seq` field for the snapshot-dedupe dance
|
||||
(see `docs/web-ui.md`). Request/response types live in `hive-sh4re`
|
||||
— change them in one place. The dashboard event vocabulary lives
|
||||
in `hive-c0re::dashboard_events::DashboardEvent`.
|
||||
|
||||
## Async forms
|
||||
|
||||
Dashboard + per-agent mutating forms carry `data-async`; a delegated
|
||||
`submit` listener in `assets/tabs.js` intercepts, shows a spinner,
|
||||
POSTs `application/x-www-form-urlencoded` (axum's `Form` extractor
|
||||
rejects multipart), calls `refreshState()` on success. New mutating
|
||||
forms should add `data-async` and optionally `data-confirm` (for a
|
||||
JS-side `confirm()` prompt) or `data-prompt="…"` (for a
|
||||
`window.prompt()` whose answer goes into a hidden input named by
|
||||
`data-prompt-field`, default `note`).
|
||||
|
||||
`refreshState` defers automatically when `document.activeElement`
|
||||
sits inside a managed section so the operator's typing isn't lost;
|
||||
collapsible `<details data-restore-key=…>` survive the re-render
|
||||
via `snapshotOpenDetails` / `restoreOpenDetails`.
|
||||
|
||||
## `rebuild` is the reconcile verb
|
||||
|
||||
`lifecycle::rebuild` idempotently rewrites
|
||||
`/etc/nixos-containers/<C>.conf` (`PRIVATE_NETWORK=0`, clears
|
||||
`HOST_ADDRESS` / `LOCAL_ADDRESS`, sets `EXTRA_NSPAWN_FLAGS`),
|
||||
regenerates `applied/<name>/flake.nix`, writes the systemd limits
|
||||
drop-in, then `nixos-container update` + stop + start.
|
||||
|
||||
Anything that changes per-container state on the host should be
|
||||
re-applied here so a manual `↻ R3BU1LD` from the dashboard is
|
||||
sufficient to recover.
|
||||
|
||||
## Actions are factored
|
||||
|
||||
`approve` / `deny` / `destroy` (and the lifecycle helper) live in
|
||||
`actions.rs` / `dashboard.rs`. The admin socket and the dashboard
|
||||
POST handlers both call into them so the two surfaces never drift.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Short, lowercase, no `Co-Authored-By` trailer. Imperative mood, no
|
||||
period. Body explains *why* if non-obvious; otherwise the subject
|
||||
alone is fine. Wrap at ~72 cols.
|
||||
|
||||
## Commit before test
|
||||
|
||||
Stage and commit when work *looks* ready, then run validation
|
||||
(`cargo check`, `nix flake check`, real deploy). Failures get a
|
||||
follow-up commit rather than an amend. The commit history is the
|
||||
work log; rewriting it loses signal.
|
||||
73
docs/damocles-migration.md
Normal file
73
docs/damocles-migration.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Migrating damocles onto hyperhive
|
||||
|
||||
The plan calls out damocles → hyperhive as a future migration. This doc lays
|
||||
out the options + recommended path. Not yet executed.
|
||||
|
||||
## Current state (separate from hyperhive)
|
||||
|
||||
`damocles` is a declarative nixos-container on `muede-lpt2`:
|
||||
|
||||
- Declared in `nixosConfigurations/muede-lpt2/containers.nix`
|
||||
- Built from `nixosConfigurations/damocles/` (claude-container.nix + extras)
|
||||
- Bind-mounts (RO unless noted):
|
||||
- `/etc/nix/distributed-build-key`
|
||||
- `/persist/damocles-ssh`
|
||||
- `/persist/damocles-lab` (RW — persistent work dir)
|
||||
- `privateNetwork = false`
|
||||
- Has its own systemd-services override (`TimeoutStopSec = 60s`, `RestartSec = 5s`)
|
||||
- Hosts the user's primary day-job Claude Code session, not part of the swarm
|
||||
|
||||
## Options
|
||||
|
||||
### A. Sub-agent under hive-c0re
|
||||
Make damocles a `hive-agent-damocles` (or whatever short name fits the 9-char cap).
|
||||
hive-c0re owns its lifecycle; its config flake is the manager-editable
|
||||
`/var/lib/hyperhive/agents/damocles/config/`.
|
||||
|
||||
Pros: uniform — message broker, dashboard, approval flow apply to damocles too.
|
||||
Cons: a lot of damocles-specific state (bind-mounts, ssh keys, build keys) has to
|
||||
be modeled as per-agent config. Today's `agent.nix` schema doesn't support
|
||||
declaring bind-mounts; would need to extend. And the user's day-job session
|
||||
becoming subject to hive-c0re lifecycle (restarts on rebuild) is invasive.
|
||||
|
||||
### B. Peer container (broker-integrated, lifecycle-independent)
|
||||
Keep damocles declarative; have its harness install `hive-ag3nt` and connect to
|
||||
the broker via a bind-mounted socket. damocles can send/recv messages with
|
||||
other sub-agents but is not managed by hive-c0re.
|
||||
|
||||
Pros: low blast radius. damocles keeps its bind-mounts + its own restart policy.
|
||||
Manager can route messages to it (it's just another inbox key on the broker).
|
||||
Cons: two lifecycle mechanisms coexist forever; "damocles" doesn't appear in the
|
||||
dashboard's container list (it filters `hive-` and `hm1nd`).
|
||||
|
||||
### C. Don't migrate
|
||||
damocles stays out of hyperhive. The two systems coexist; the user's day-job
|
||||
Claude and the swarm are deliberately separate.
|
||||
|
||||
Pros: zero work; aligns with the actual usage pattern (day-job vs. experiment).
|
||||
Cons: no message routing between damocles and the swarm.
|
||||
|
||||
## Recommended
|
||||
|
||||
**C for now, B once cross-pollination is wanted.** Hyperhive's invariants
|
||||
(11-char container names, manager-driven lifecycle, sealed `applied/` config)
|
||||
fit poorly with damocles's role as the user's working Claude. Wait until there's
|
||||
a concrete reason to wire them together (e.g. "I want to ask hm1nd from inside
|
||||
damocles") and then do B — extend the broker socket bind into damocles and
|
||||
install `hive-ag3nt` there. No need to subsume damocles under hive-c0re.
|
||||
|
||||
If/when option B is taken:
|
||||
|
||||
1. Add `${hyperhive.packages.${system}.default}/bin/hive-ag3nt` (or just
|
||||
`pkgs.hyperhive`) to `nixosConfigurations/damocles/claude-container.nix`.
|
||||
2. Bind-mount `/run/hyperhive/agents/damocles/` into damocles at `/run/hive/`.
|
||||
On the host, this is just another dir hive-c0re needs to know about —
|
||||
maybe expose a "peer agents" registration mechanism in the broker.
|
||||
3. Run `hive-ag3nt serve` as a systemd unit inside damocles (separate from
|
||||
the user's interactive claude session — broker peer, not turn-loop).
|
||||
4. Optionally: add a `hive-c0re register-peer damocles` admin verb so the
|
||||
container appears in `list()` and the dashboard. (Or just hard-list it.)
|
||||
|
||||
A is on the table only if the user's workflow shifts toward "the swarm IS the
|
||||
day-job environment" — at which point damocles dissolves into a `hive-agent-*`
|
||||
naturally.
|
||||
149
docs/gotchas.md
Normal file
149
docs/gotchas.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# Gotchas
|
||||
|
||||
NixOS + nspawn quirks and lessons we hit the hard way. If something
|
||||
here looks unmotivated in the code, there's usually a story underneath.
|
||||
|
||||
## `nixos-container` doesn't expose `--bind` on the CLI
|
||||
|
||||
The CLI doesn't accept `--bind`. Path is via `EXTRA_NSPAWN_FLAGS` in
|
||||
`/etc/nixos-containers/<NAME>.conf` — the start script
|
||||
(`/nix/store/.../container_-start`) expands it unquoted into the
|
||||
`systemd-nspawn` invocation. `lifecycle::set_nspawn_flags()` rewrites
|
||||
this line.
|
||||
|
||||
## `/run/systemd/nspawn/*.nspawn` overrides are ignored
|
||||
|
||||
`nixos-container`'s start script builds the nspawn command line
|
||||
directly. Dropping a `.nspawn` file under `/run/systemd/nspawn/`
|
||||
looks like the obvious extension point and does nothing. Use
|
||||
`EXTRA_NSPAWN_FLAGS` (above).
|
||||
|
||||
## `boot.isNspawnContainer = true`
|
||||
|
||||
Not `boot.isContainer = true`. Renamed in nixos-25.11+.
|
||||
|
||||
## `nixos-container create` auto-assigns `HOST_ADDRESS` / `LOCAL_ADDRESS`
|
||||
|
||||
…in the `.conf`. The start script's `if HOST_ADDRESS set →
|
||||
--network-veth` branch then forces a private netns — silently fatal
|
||||
for our web UIs (the bind is invisible from the host). We
|
||||
force-clear `HOST_ADDRESS` / `LOCAL_ADDRESS` / `HOST_ADDRESS6` /
|
||||
`LOCAL_ADDRESS6` / `HOST_BRIDGE` and set `PRIVATE_NETWORK=0`.
|
||||
|
||||
## systemd service PATH ≠ host PATH
|
||||
|
||||
The hive-c0re service sets `path = [ pkgs.git "/run/current-system/sw" ]`.
|
||||
In-container harness services do the same so anything an agent adds
|
||||
to its own `agent.nix` (`environment.systemPackages`) is visible to
|
||||
claude's Bash tool without editing the service definition.
|
||||
`environment.HYPERHIVE_GIT` bakes git's absolute path in (read by
|
||||
`lifecycle::git_command()`) for the host.
|
||||
|
||||
## `RuntimeDirectoryPreserve = "yes"`
|
||||
|
||||
…keeps `/run/hyperhive/` (and the per-agent sub-dirs) across
|
||||
hive-c0re restarts. Without it, every restart wipes bind sources and
|
||||
existing containers can't be started.
|
||||
|
||||
## `register_agent` is idempotent
|
||||
|
||||
Drops any prior socket task before rebinding. Required so a
|
||||
hive-c0re restart followed by `rebuild alice` recreates the agent's
|
||||
socket without needing a clean reinstall.
|
||||
|
||||
## `claude-code` is unfree
|
||||
|
||||
The flake pins it to **nixpkgs-unstable** via
|
||||
`overlays.claude-unstable` (stable lags too far). The overlay sets
|
||||
`config.allowUnfreePredicate` on its unstable import to whitelist
|
||||
`claude-code` specifically — scoped, only this one package.
|
||||
`harness-base.nix` does the same at the container level because
|
||||
each per-agent `nixosConfiguration` evaluates its own nixpkgs
|
||||
instance and the operator's host-level `allowUnfree` does **not**
|
||||
propagate in. Operators don't need to set anything on their side.
|
||||
|
||||
## Claude credentials are per-agent
|
||||
|
||||
`/var/lib/hyperhive/agents/<name>/claude/` bind-mounts to
|
||||
`/root/.claude` (RW). Sharing one dir across agents is NOT viable —
|
||||
OAuth refresh tokens rotate, so any sibling refresh invalidates all
|
||||
the others. Login flow runs from the per-agent web UI; creds persist
|
||||
across `destroy`/recreate (`--purge` wipes them).
|
||||
|
||||
## Persistent notes dir per agent
|
||||
|
||||
`/var/lib/hyperhive/agents/<name>/state/` bind-mounts to `/state`
|
||||
(RW). System prompts tell agents to keep durable knowledge here
|
||||
(`/state/notes.md`, anything else under `/state/`). The harness also
|
||||
writes its events log here (`/state/hyperhive-events.sqlite`).
|
||||
Survives `destroy`/recreate alongside the claude dir.
|
||||
|
||||
## Web UI ports collide on hash
|
||||
|
||||
Sub-agent web UI ports are deterministic FNV-1a of the agent name
|
||||
modulo 900 (range 8100..8999). With ~30 agents the birthday-paradox
|
||||
collision rate gets meaningful; at 2–3 agents you can still get
|
||||
unlucky. Operator resolves a collision by renaming the offending
|
||||
agent (different hash → different port) and rebuilding. No state
|
||||
file, no probing, no port-allocation drift — the value is
|
||||
reproducible from just the name. Manager is fixed at 8000;
|
||||
dashboard at `cfg.dashboardPort` (default 7000).
|
||||
|
||||
## Restart races on TCP bind
|
||||
|
||||
Both the dashboard and per-agent web UI use `tokio::net::TcpSocket`
|
||||
with `SO_REUSEADDR` plus a retry-on-`AddrInUse` loop (12 tries,
|
||||
exponential backoff capped at 2s, ~22s total). REUSEADDR handles
|
||||
the `TIME_WAIT` case from a clean previous exit; retry covers the
|
||||
genuine "previous process is still alive during a systemd restart
|
||||
overlap" case. REUSEADDR does **not** allow two simultaneous
|
||||
`LISTEN` sockets on the same port (that would be `SO_REUSEPORT`,
|
||||
which we don't use) — exclusivity is preserved.
|
||||
|
||||
## Orphan approvals
|
||||
|
||||
If state dirs are wiped out from under a pending approval (test
|
||||
scripts, manual `rm -rf`), the dashboard's next render marks them
|
||||
`failed` with note `"agent state dir missing"` so they fall out of
|
||||
`pending`. They stay in sqlite for audit.
|
||||
|
||||
## Nix store `cp -r` preserves read-only bits
|
||||
|
||||
Copying a nix store path with `cp -r src/. $out/` inside a
|
||||
`pkgs.runCommand` derivation preserves the read-only permissions of
|
||||
store files. Any subsequent write into the copied tree (adding new
|
||||
files in subdirectories) fails with `EPERM`. Fix: pass
|
||||
`--no-preserve=mode,ownership` so the output tree is writable.
|
||||
|
||||
## `hive-forge`: prefer over raw curl pipelines
|
||||
|
||||
Every agent container has `hive-forge` in PATH (installed via
|
||||
`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary
|
||||
since #280). Use it instead of ad-hoc curl pipelines:
|
||||
|
||||
```bash
|
||||
hive-forge view 42 # title + body + comments
|
||||
hive-forge comments 42 # list all comments (human-readable)
|
||||
hive-forge --json comments 42 # same as above, JSON array (global flag, closes #421)
|
||||
hive-forge comment 42 --body "..." # post comment (inline body)
|
||||
hive-forge comment 42 --body-file - <<EOF # ...or pipe a HEREDOC
|
||||
multi-line body
|
||||
EOF
|
||||
hive-forge assign 42 damocles
|
||||
hive-forge close 42
|
||||
hive-forge labels 42 add feature
|
||||
hive-forge pr 42 # PR metadata as JSON
|
||||
hive-forge diff 42 # unified diff (lockfile hunks collapsed by default)
|
||||
hive-forge diff 42 --full # include unfiltered lockfile hunks
|
||||
hive-forge branches deployed/ # filter branches by pattern
|
||||
hive-forge -r other-org/other-repo pr 7 # target a different repo
|
||||
hive-forge lint unassigned # open issues/PRs with no assignee
|
||||
hive-forge lint no-reviewer --reviewer argus # PRs missing a reviewer comment from argus
|
||||
hive-forge lint stale-branches --days 14 # branches with no recent activity
|
||||
hive-forge lint assignments # per-assignee open item count
|
||||
```
|
||||
|
||||
`hive-forge <verb> --help` prints the full signature for any verb.
|
||||
Credentials come from `$HYPERHIVE_STATE_DIR/forge-token`; default
|
||||
repo from `$HIVE_FORGE_REPO`, overridden per-invocation by the
|
||||
global `-r/--repo` flag.
|
||||
167
docs/persistence.md
Normal file
167
docs/persistence.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# Persistence + retention
|
||||
|
||||
Where state lives, what survives what, and how it's bounded.
|
||||
|
||||
## Two sqlite databases
|
||||
|
||||
### `/var/lib/hyperhive/broker.sqlite` (host)
|
||||
|
||||
Three tables, all in one file:
|
||||
|
||||
- `messages` — every inter-agent / operator-bound message.
|
||||
`sender / recipient / body / sent_at / delivered_at / acked_at /
|
||||
in_reply_to`. `in_reply_to` links a reply to its parent row id;
|
||||
the dashboard and per-agent inbox render these as threaded rows.
|
||||
- `approvals` — the queue. `agent / kind (apply_commit | spawn) /
|
||||
commit_ref / requested_at / status / resolved_at / note`.
|
||||
- `operator_questions` — `ask` / `answer` queue (despite the
|
||||
file name, stores both operator-targeted + agent-to-agent
|
||||
questions since the `ask` rename).
|
||||
`asker / question / options_json / multi / asked_at /
|
||||
deadline_at (ttl) / answered_at / answer / target`. `target IS
|
||||
NULL` = operator path (dashboard); `target = '<agent>'` = peer
|
||||
Q&A (`HelperEvent::QuestionAsked` pushed into target's inbox,
|
||||
answered via `Answer` request). Migrated via `ALTER TABLE ADD
|
||||
COLUMN` against `pragma_table_info`.
|
||||
|
||||
Retention:
|
||||
|
||||
- `Broker::vacuum_delivered` runs hourly via a tokio task in
|
||||
`hive-c0re::main`. Drops delivered rows older than 30 days.
|
||||
Undelivered rows are always kept (still in flight).
|
||||
- Approvals and questions are kept indefinitely — both are
|
||||
audit trails. `actions::destroy` and answered questions stay
|
||||
visible to anything that queries by id.
|
||||
|
||||
### `/state/hyperhive-events.sqlite` (per agent)
|
||||
|
||||
Lives inside each container's bind-mounted `/state/` dir (host
|
||||
path: `/var/lib/hyperhive/agents/<name>/state/hyperhive-events.sqlite`).
|
||||
One table:
|
||||
|
||||
- `events(id, ts, kind, payload_json)` — every `LiveEvent` the
|
||||
harness emits during turn loop execution.
|
||||
|
||||
The harness writes; the host vacuums. `hive-c0re::events_vacuum`
|
||||
runs hourly and sweeps every existing agent state dir, deleting
|
||||
rows older than 7 days. Age-only — no row cap — so a chatty turn
|
||||
doesn't lose history sooner than a quiet one; disk pressure on a
|
||||
sustained burst is the cheaper problem to have. Centralising
|
||||
retention on the host means a misbehaving harness can't disable
|
||||
its own vacuum and agents don't need any cleanup wiring of their
|
||||
own.
|
||||
|
||||
Path overridable via `HYPERHIVE_EVENTS_DB` (for dev / no-`/state`
|
||||
setups). On open failure the `Bus` falls back to no-store mode
|
||||
rather than crashing the harness — events still broadcast over SSE,
|
||||
just nothing persisted.
|
||||
|
||||
### `/state/hyperhive-turn-stats.sqlite` (per agent)
|
||||
|
||||
Per-turn analytics sink. One row per claude turn captures
|
||||
identity (`model`, `wake_from`, `result_kind`), timing
|
||||
(`started_at`, `ended_at`, `duration_ms`), cost (input / output /
|
||||
cache_read / cache_creation token counts), behaviour
|
||||
(`tool_call_count` + `tool_call_breakdown_json`), and post-turn
|
||||
snapshot metrics (`open_threads_count`,
|
||||
`open_reminders_count` — fetched via the same socket the harness
|
||||
already uses for `GetOpenThreads` + `CountPendingReminders`).
|
||||
Bin-loop helpers `build_row` + `record` land each row at
|
||||
`turn_end`; writes are best-effort, a sqlite hiccup logs + lets
|
||||
the turn loop continue.
|
||||
|
||||
No host-side vacuum yet — tracked as forge issue
|
||||
[#10](http://localhost:3000/hyperhive/hyperhive/issues/10)
|
||||
(target retention ~90 days, age-only sweep like events_vacuum).
|
||||
|
||||
### `/state/hyperhive-rate-limited` (per agent)
|
||||
|
||||
Sentinel file written by `Bus::emit_status("rate_limited")` when the
|
||||
harness detects a 429 / rate-limit response from the Claude API, and
|
||||
removed when the retry sleep expires (any subsequent status emit
|
||||
clears it). The file's presence is checked by hive-c0re's
|
||||
`container_view::is_rate_limited` on each `build_all` sweep (~10s) to
|
||||
populate `ContainerView.rate_limited` for the dashboard. Survives a
|
||||
harness restart (the Bus reads it back at boot and restores the flag),
|
||||
so the badge remains accurate if hive-c0re restarts while the harness
|
||||
is mid-sleep.
|
||||
|
||||
### `/state/hyperhive-model` (per agent)
|
||||
|
||||
Single-line text file holding the claude model name currently
|
||||
selected for this agent (default `haiku` when absent). Written by
|
||||
`Bus::set_model` whenever the operator flips it via `/model
|
||||
<name>` in the web terminal. Read once at harness boot in
|
||||
`Bus::new`. Path overridable via `HYPERHIVE_MODEL_FILE`.
|
||||
Survives destroy/recreate, gone on `--purge`.
|
||||
|
||||
## State dirs (per agent)
|
||||
|
||||
Under `/var/lib/hyperhive/agents/<name>/`:
|
||||
|
||||
- `config/` — the proposed nix repo (manager-editable). Bind-mounted
|
||||
**read-only** to `/agents/<name>/config` inside the sub-agent's own
|
||||
container so the agent can inspect what defines it and request
|
||||
precise changes from the manager; RW into the manager via the
|
||||
`/agents` tree bind.
|
||||
- `claude/` — claude OAuth credentials, bind-mounted RW to
|
||||
`/root/.claude` inside the container.
|
||||
- `state/` — durable notes, the events.sqlite db, and the
|
||||
turn-stats sqlite db. Bind-mounted to `/agents/<name>/state`
|
||||
inside the container (the manager still uses the legacy
|
||||
`/state` mount point — same host path either way).
|
||||
|
||||
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
|
||||
applied repo. Tracks `flake.nix` (module-only boilerplate; never
|
||||
edited after first spawn) + `agent.nix` (the actual config; the
|
||||
manager's edits land here via the approval flow) + any other
|
||||
files the manager committed. `.git/` carries the proposal /
|
||||
approved / building / deployed / failed / denied tag history.
|
||||
|
||||
Under `/var/lib/hyperhive/meta/` — the swarm-wide deploy flake.
|
||||
Single repo for the whole host; `flake.nix` declares one input
|
||||
per agent + one `nixosConfigurations.<n>` output per agent;
|
||||
`flake.lock` is the canonical "what's deployed where." The git
|
||||
log is the deploy audit trail (one commit per successful
|
||||
deploy or hyperhive bump). Manager has this RO-mounted at
|
||||
`/meta/`.
|
||||
|
||||
Marker file `/var/lib/hyperhive/.meta-migration-done` is
|
||||
written by the startup migration after every container has
|
||||
been repointed at `meta#<n>`. Removing it forces a re-run on
|
||||
next hive-c0re start (idempotent — only the actual repoint
|
||||
step would re-fire).
|
||||
|
||||
## Destroy vs purge
|
||||
|
||||
- `DESTR0Y` (default) — stops + removes the nspawn container,
|
||||
drops the systemd drop-in, fails any pending approvals. State
|
||||
dirs stay put; the agent appears in the dashboard's K3PT ST4T3
|
||||
section as a tombstone with `⊕ R3V1V3` and `PURG3` actions.
|
||||
`R3V1V3` queues a Spawn approval that reuses the kept state on
|
||||
approve (no re-login).
|
||||
- `PURG3` (opt-in via the dashboard button or
|
||||
`hive-c0re destroy --purge <name>`) — DESTR0Y plus wipes
|
||||
`/var/lib/hyperhive/{agents,applied}/<name>/`. Config history,
|
||||
claude creds, /state/ notes, and the events db are all gone.
|
||||
No undo.
|
||||
|
||||
The manager is non-destroyable from both paths (declarative
|
||||
container; would fight with the host's NixOS config).
|
||||
|
||||
## Run-time dirs
|
||||
|
||||
`/run/hyperhive/` is tmpfs-backed (systemd `RuntimeDirectory=`) but
|
||||
preserved across hive-c0re restarts via `RuntimeDirectoryPreserve=yes`.
|
||||
Without that, every restart wipes bind sources and existing
|
||||
containers can't be started.
|
||||
|
||||
- `/run/hyperhive/host.sock` — admin socket (host-side CLI).
|
||||
- `/run/hyperhive/manager/mcp.sock` — manager-privileged socket.
|
||||
- `/run/hyperhive/agents/<name>/mcp.sock` — per-sub-agent socket
|
||||
(bind-mounted into the container as `/run/hive/mcp.sock`).
|
||||
|
||||
On startup, `Coordinator::register_agent` drops any prior socket
|
||||
task before rebinding — idempotent so a hive-c0re restart followed
|
||||
by `rebuild alice` recreates the agent's socket without a clean
|
||||
reinstall.
|
||||
33
docs/security.md
Normal file
33
docs/security.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Security model
|
||||
|
||||
## Nix builds and credential isolation (issue #240)
|
||||
|
||||
### Background
|
||||
|
||||
Agent containers bind-mount the host's `nix-daemon` socket. The host daemon may
|
||||
have `sandbox-fallback = false` (strict NixOS defaults), which causes `nix build`
|
||||
inside nspawn containers to fail — containers lack kernel user namespaces, so nix
|
||||
cannot set up its build sandbox. `harness-base.nix` sets `sandbox-fallback = true`
|
||||
so that builds fall back to unsandboxed execution rather than failing outright.
|
||||
|
||||
### Threat model
|
||||
|
||||
Unsandboxed nix builds run as `nixbld` users (non-root, typically UIDs 30001-30010).
|
||||
Without sandbox isolation, a build derivation's builder script has read access to
|
||||
any file in the container that the nixbld user can read.
|
||||
|
||||
**What is NOT exposed**:
|
||||
|
||||
- `/root/.claude/` — mode `0700`, owned by root. nixbld users cannot read it.
|
||||
- `/state/forge-token` — written at mode `0600` by `hive-c0re/src/forge.rs`.
|
||||
nixbld users cannot read it.
|
||||
|
||||
**Policy**: all credential files written to agent state directories MUST be mode
|
||||
`0600` or stricter. Do not create world-readable secret files in agent state dirs.
|
||||
|
||||
### Long-term fix
|
||||
|
||||
The proper fix is to enable user namespaces inside nspawn containers
|
||||
(`--private-users=inherit` in `EXTRA_NSPAWN_FLAGS`) so nix can set up its real
|
||||
sandbox and `sandbox-fallback` becomes a true last resort. This requires verifying
|
||||
bind-mount compatibility with user namespace UID mapping and is tracked as a TODO.
|
||||
114
docs/terminal-rendering.md
Normal file
114
docs/terminal-rendering.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Per-agent terminal: row taxonomy (as built)
|
||||
|
||||
Snapshot of how the per-agent web UI's live pane renders each
|
||||
event kind today. Source of truth lives in
|
||||
`frontend/packages/agent/src/app.js` (`renderStream`, `fmtToolUse`,
|
||||
`renderRichToolUse`, `renderToolResult`, `renderTaskEvent`,
|
||||
`mdNode`, `detailsOpenMd`, `fmtArgsGeneric`) +
|
||||
`frontend/packages/shared/src/terminal.css` (the shared
|
||||
`.live .<class>` styling) + the `marked` npm package (markdown).
|
||||
|
||||
## Layout contract
|
||||
|
||||
Every row — flat `<div class="row …">` and expandable
|
||||
`<details class="row …">` alike — shares one prefix column.
|
||||
The mechanism is `padding-left + negative text-indent` on
|
||||
`.live .row`: the row's first character (the prefix glyph)
|
||||
gets pulled back into the column at ~0.5em, and wrapped
|
||||
continuation lines hang under the body, not under the glyph.
|
||||
|
||||
`<details>` summaries inherit those metrics. The disclosure
|
||||
marker (`▸` / `▾`) is supplied by CSS `summary::before` so it
|
||||
lands in the same column as flat-row glyphs. To make that
|
||||
work the JS-side summary text **does not** include a
|
||||
directional `→` / `←` — the row's colour (cyan = outbound,
|
||||
muted = inbound) carries the direction, and the prefix
|
||||
column never has to fit two glyphs side-by-side.
|
||||
|
||||
Child blocks inside a row (the `.md` markdown wrapper, an
|
||||
inner `<details>`) get `text-indent: 0` so their content
|
||||
lays out from the body column instead of inheriting the
|
||||
parent's negative pull.
|
||||
|
||||
## Row taxonomy
|
||||
|
||||
| CSS class | Prefix glyph | Color | Triggered by | Source |
|
||||
|---|---|---|---|---|
|
||||
| `.turn-start` | `◆ TURN ← <from>` | amber, left rule | `LiveEvent::TurnStart` | harness wake |
|
||||
| `.turn-body` | (child div under turn-start) | fg | same | the wake-prompt body |
|
||||
| `.turn-end-ok` | `✓ turn ok` | green, left rule | `LiveEvent::TurnEnd { ok: true }` | harness |
|
||||
| `.turn-end-fail` | `✗ turn fail — note` | red, left rule | `LiveEvent::TurnEnd { ok: false }` | harness |
|
||||
| `.text` | (no prefix; markdown body) | fg | claude `assistant.content[].text` | stream-json |
|
||||
| `.thinking` | `· thinking …` | muted, italic | claude `assistant.content[].thinking` | stream-json |
|
||||
| `.tool-use` (flat) | `→ Name args…` | cyan | tool_use w/o rich renderer | stream-json |
|
||||
| `.tool-use` `<details>` | `Write/Edit <path> · +N` (no `→`) | cyan, body is +/- diff | `renderRichToolUse` Write/Edit | stream-json |
|
||||
| `.tool-use` `<details open>` | `send → to · NL`, `ask → to`, `answer #id` | cyan, body is markdown | rich renderer for send / ask / answer | stream-json |
|
||||
| `.tool-result` (flat) | `← <txt>` | muted | short `tool_result` (≤120c, non-recv) | stream-json |
|
||||
| `.tool-result-block` `<details>` | `Nl · headline` | muted, body is text | long generic `tool_result` | stream-json |
|
||||
| `.tool-result-block` `<details open>` | `recv ← <txt>` | muted, body is markdown | `tool_result` correlated to a prior `recv` tool_use via id | stream-json |
|
||||
| `.tool-use` | `⌁ task <id> started · <desc> [type]` | cyan | claude Task-tool subagent start | `renderTaskEvent` |
|
||||
| `.turn-end-ok` / `.turn-end-fail` / `.tool-result` | `⌁ task <id> ✓/✗/◌ <status> · <desc> · → <output_file>` | green / red / muted | claude Task-tool result | `renderTaskEvent` |
|
||||
| `.note` | `· <text>` | muted | harness chatter | `LiveEvent::Note` |
|
||||
| `.note.stderr` | `! stderr: <line>` | amber/orange | stderr lines off claude | `LiveEvent::Note` (`text` starts `stderr:`) |
|
||||
| `.note.op` | `· operator: <text>` | mauve italic | operator-initiated notes (/cancel, /compact, /model, new-session) | `LiveEvent::Note` (`text` starts `operator:`) |
|
||||
| `.sys` | `! {json…}` | amber/orange | catch-all for stream shapes `renderStream` didn't classify | catch-all |
|
||||
| Banner shimmer | mauve | turn in flight (ref-counted) | — | `setBannerActive` |
|
||||
|
||||
## Renderer dispatch
|
||||
|
||||
`renderStream(v, api)` walks each stream-json line:
|
||||
|
||||
1. Drops `system/init`, `rate_limit_event`, `result` (noise /
|
||||
handled elsewhere — `result` powers the `cost` badge).
|
||||
2. `subtype == "task_started" | "task_notification"` →
|
||||
`renderTaskEvent` (subagent activity gets the `⌁` glyph).
|
||||
3. `type == "assistant"` → walk `message.content[]`:
|
||||
- `text` → `.text` row with a markdown body via `mdNode`.
|
||||
- `thinking` → `.thinking` row.
|
||||
- `tool_use` → record `id → name` in `toolNameById`, try
|
||||
`renderRichToolUse` (Write/Edit/send/ask/answer get
|
||||
custom renderings); on miss fall through to a flat
|
||||
`.tool-use` row with `fmtToolUse → fmtArgsGeneric`.
|
||||
`fmtToolUse` surfaces the salient arg per built-in tool —
|
||||
e.g. `recv` shows `wait <N>s` / `max <N>` when set (bare
|
||||
`recv()` otherwise), `Bash` flags `[bg]` for
|
||||
`run_in_background` commands.
|
||||
4. `type == "user"` → walk `message.content[]` for
|
||||
`tool_result`; `renderToolResult` correlates via
|
||||
`tool_use_id → toolNameById` to default-open `recv`
|
||||
results with a markdown body, else short = flat /
|
||||
long = collapsed details.
|
||||
5. Unrecognised shape → `.sys` row (amber, `!` glyph).
|
||||
|
||||
## Markdown
|
||||
|
||||
`mdNode(text)` wraps `marked.parse(text)` (the `marked` v4.x npm
|
||||
dep, bundled by esbuild into the page's `app.js`) in a `<div
|
||||
class="md">`. CSS in `terminal.css` scopes paragraph / code /
|
||||
list / blockquote / link styling under `.live .row .md` so
|
||||
the markdown body doesn't bleed into the row's own
|
||||
text-indent. Falls back to plain text if `marked` didn't
|
||||
load. Applied to `text` rows and to send / ask / answer /
|
||||
recv message bodies.
|
||||
|
||||
## Extra-MCP tools
|
||||
|
||||
`fmtArgsGeneric(name, input)` is the fallback when a tool
|
||||
isn't in the built-in `fmtToolUse` switch:
|
||||
|
||||
- single string field → `name k: "v"`
|
||||
- single number/bool field → `name k: v`
|
||||
- multi-field → first 4 pairs trimmed to `k: "v"` /
|
||||
`k: [N]` / `k: {…}` with a `…+N` overflow
|
||||
|
||||
This keeps `mcp__matrix__send_message` and similar from
|
||||
dumping raw JSON.
|
||||
|
||||
## Dashboard side (not covered here)
|
||||
|
||||
The main dashboard's message-flow pane is a different
|
||||
shape: broker messages render as `.msgrow` grid lines (ts /
|
||||
arrow / from / → / to / body) with their own styling.
|
||||
`.live .msgrow` explicitly resets `text-indent: 0` so the
|
||||
per-agent terminal's hanging-indent metrics don't leak into
|
||||
the flex-grid broker rows.
|
||||
423
docs/turn-loop.md
Normal file
423
docs/turn-loop.md
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
# Turn loop + MCP
|
||||
|
||||
How the harness wakes up, what it asks claude to do, and what tools
|
||||
claude has access to in return.
|
||||
|
||||
## The loop
|
||||
|
||||
Each agent harness (`hive-ag3nt serve` or `hive-m1nd serve`) runs:
|
||||
|
||||
1. Long-poll `Recv` on its socket. The host-side broker
|
||||
(`broker.rs::recv_blocking_batch`) returns immediately if there's
|
||||
a pending message, otherwise waits up to 30 s for a broker `Sent`
|
||||
event for this recipient.
|
||||
2. Pop one message. Peek the remaining inbox depth with `Status`.
|
||||
3. Emit `LiveEvent::TurnStart { from, body, unread }` onto the SSE
|
||||
bus.
|
||||
4. Spawn claude (one process per turn) and pipe the wake prompt
|
||||
over stdin.
|
||||
5. Stream stdout (JSON lines) into the bus as
|
||||
`LiveEvent::Stream(value)`. Pump stderr as `Note`.
|
||||
6. Wait for claude to exit. Compaction is two-pronged — *reactive*
|
||||
on `Prompt is too long` and *proactive* on a context watermark
|
||||
(see [Compaction](#compaction) below). **Rate-limit detection**:
|
||||
on stderr the harness does a raw-line match for `429` /
|
||||
`rate_limit` markers; on stdout it only fires on parsed
|
||||
`{"type":"error"}` JSON events (avoiding false positives when
|
||||
agents discuss `rate_limit_error` in conversation text). On
|
||||
detection the harness sets the `rate_limited` sentinel
|
||||
(`Bus::emit_status("rate_limited")`), sleeps
|
||||
`HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), then retries.
|
||||
The dashboard and per-agent page show a `⊘ rate limited` badge
|
||||
while the harness is parked. **Auth-failed detection** (closes
|
||||
#419): both stdout and stderr pumps also match
|
||||
`AUTH_FAIL_MARKERS` (`"authentication_failed"`, `401`, etc.).
|
||||
On match the harness writes `{state_dir}/hyperhive-needs-login`,
|
||||
emits `needs_login_idle` status, requeues the inflight message
|
||||
(so it replays after re-auth), and parks in `wait_for_login` —
|
||||
the same path used at boot. The operator re-authenticates via
|
||||
the per-agent web UI login flow; on success the sentinel is
|
||||
cleared and the queued message drives the next turn normally.
|
||||
7. Emit `LiveEvent::TurnEnd { ok, note }`. Sleep `poll_ms` to avoid
|
||||
tight loops on transient failures.
|
||||
|
||||
## The claude invocation
|
||||
|
||||
```
|
||||
claude --print --verbose --output-format stream-json --model <name> \
|
||||
--continue --settings /run/hive/claude-settings.json \
|
||||
--system-prompt-file /run/hive/claude-system-prompt.md \
|
||||
--mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \
|
||||
--tools <builtins> --allowedTools <builtins+mcp>
|
||||
# wake prompt piped over stdin
|
||||
```
|
||||
|
||||
`<name>` is read from `Bus::model()` on each turn. The initial
|
||||
default is set by `hyperhive.model` in the agent's `agent.nix`
|
||||
(NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls
|
||||
back to `"haiku"` if unset). The operator can flip it at runtime
|
||||
with `/model <name>` in the web terminal — the next turn picks it
|
||||
up. The choice is persisted to `/state/hyperhive-model` so it
|
||||
survives restart; override path: `HYPERHIVE_MODEL_FILE` env var
|
||||
for tests.
|
||||
|
||||
Context-window size is looked up per-model via
|
||||
`events::context_window_tokens(model)`. Resolution order (first
|
||||
match wins):
|
||||
|
||||
1. `HIVE_CONTEXT_WINDOW_TOKENS_<KEY>` env var, where `KEY`
|
||||
(lowercased) is a substring of the active model name. Injected
|
||||
by the meta flake from `services.hive-c0re.contextWindowTokens`
|
||||
(host-level NixOS option, defaults: haiku=200k, sonnet=1M,
|
||||
opus=1M). Override these for all agents at once without a
|
||||
per-agent config change.
|
||||
2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override for any
|
||||
model (useful in dev / test).
|
||||
3. Hard fallback: `200_000` (conservative; only reached outside
|
||||
NixOS where the env vars aren't set).
|
||||
|
||||
The effective window drives watermarks and is exposed at runtime
|
||||
via `/api/state.context_window_tokens` so the UI can show a
|
||||
percentage-of-window ctx badge.
|
||||
|
||||
`--continue` keeps a persistent session per agent (claude stores
|
||||
sessions in `~/.claude/projects/`, which is bind-mounted
|
||||
persistently). Auto-compact and auto-memory are disabled via
|
||||
`--settings` because hyperhive owns compaction — see
|
||||
[Compaction](#compaction) below.
|
||||
A one-shot `--continue` suppression is available via
|
||||
`POST /api/new-session` (or `/new-session` slash command in the
|
||||
per-agent terminal) — `Bus::take_skip_continue()` flips an
|
||||
`AtomicBool` once per turn, the next claude invocation drops
|
||||
`--continue`, every subsequent turn resumes normal behaviour.
|
||||
|
||||
### Compaction
|
||||
|
||||
claude's own in-session auto-compact is off (`--settings`); hyperhive
|
||||
owns it explicitly in `turn::drive_turn`. There are two triggers:
|
||||
|
||||
- **Reactive** — claude-code prints `Prompt is too long` (the
|
||||
`PROMPT_TOO_LONG_MARKER`). The session is *already* past the context
|
||||
window, so no turn can run on it — `drive_turn` runs `/compact`
|
||||
straight away and retries the same wake-up prompt once. No
|
||||
notes-checkpoint turn is possible here: the detail is gone.
|
||||
- **Proactive** — a turn finishes cleanly but the last inference's
|
||||
context size (`Bus::last_ctx_usage().context_tokens()`) is at or
|
||||
above a watermark. While the session is still healthy, `drive_turn`
|
||||
injects one synthetic *notes-checkpoint* turn (`CHECKPOINT_PROMPT`
|
||||
— "context is filling up, flush durable state into `/state` now")
|
||||
and *then* runs `/compact`. This gives the agent a chance to
|
||||
persist in-flight task state, decisions, and file paths before the
|
||||
conversation detail collapses into a summary.
|
||||
|
||||
The compact watermark defaults to **75% of `context_window_tokens(model)`**
|
||||
(dynamically derived — 150k for haiku, 750k for sonnet/opus). Override
|
||||
with `HIVE_COMPACT_WATERMARK_TOKENS` (absolute token count); set to `0`
|
||||
to disable proactive compaction entirely (the reactive path always
|
||||
applies). The proactive path is best-effort — a failed checkpoint turn
|
||||
or `/compact` is surfaced as a `Note` but never fails the turn that
|
||||
already succeeded. The operator can also force a compaction any time
|
||||
via `/api/compact`.
|
||||
|
||||
- **Auto session-reset** — a third path that fires when both
|
||||
conditions hold: context is ≥ a watermark (`HIVE_AUTO_RESET_WATERMARK_TOKENS`,
|
||||
default **50% of `context_window_tokens(model)`**) AND the time since
|
||||
the last turn exceeds the assumed prompt-cache TTL
|
||||
(`HIVE_CACHE_TTL_SECS`, default `3600`).
|
||||
Claude's prompt cache lives ~5 minutes; if the cache is already
|
||||
cold, resuming with `--continue` pays the full re-upload cost of
|
||||
the current context with no benefit over starting fresh. So:
|
||||
`drive_turn` injects one `AUTO_RESET_CHECKPOINT_PROMPT` notes turn
|
||||
("flush state to files, cache is cold") then arms
|
||||
`Bus::take_skip_continue()` for the real turn — the next turn runs
|
||||
without `--continue`, starting a fresh session. Unlike proactive
|
||||
compaction the session is dropped entirely, not compacted. Set
|
||||
`HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to disable.
|
||||
|
||||
The child runs with `cwd = /state` (when the bind exists; falls
|
||||
back to the parent's cwd in dev), so any relative path in a tool
|
||||
call (`Read foo.md`, `Bash ls`, `Write notes.md`) lands in the
|
||||
agent's durable bind-mounted dir. CLAUDE.md auto-load walks
|
||||
upward from `/state` — drop a per-agent CLAUDE.md there if you
|
||||
want long-term hints that survive destroy/recreate.
|
||||
|
||||
The wake prompt is intentionally minimal: just the popped message's
|
||||
`from`/`body`, plus an inline `({unread} more pending — drain via
|
||||
…)` hint when `unread > 0`. Claude drives any further `recv`/`send`
|
||||
itself via the embedded MCP server.
|
||||
|
||||
Whenever hive-c0re starts / restarts / rebuilds a container, it
|
||||
also drops a `system` message into the agent's inbox via
|
||||
`Coordinator::kick_agent` — a one-line "you were just (re)started,
|
||||
check /state/ for your notes, --continue session is intact". The
|
||||
next turn picks it up like any other inbox message.
|
||||
|
||||
### On-boot files
|
||||
|
||||
`hive_ag3nt::turn::write_*` writes three files next to the per-agent
|
||||
socket at `/run/hive/` once at startup:
|
||||
|
||||
- `claude-mcp-config.json` — re-invokes the running binary as `mcp`
|
||||
child (so the same binary serves as harness + as claude's MCP
|
||||
child process).
|
||||
- `claude-settings.json` — the `--settings` blob (auto-compact and
|
||||
auto-memory off, effortLevel medium).
|
||||
- `claude-system-prompt.md` — rendered from
|
||||
`hive-ag3nt/prompts/{agent,manager}.md` with `{label}` and
|
||||
`{operator_pronouns}` substituted. Pronouns come from
|
||||
`HIVE_OPERATOR_PRONOUNS` env (set by the meta flake from
|
||||
`services.hive-c0re.operatorPronouns`, default `she/her`).
|
||||
Passed via `--system-prompt-file`.
|
||||
|
||||
The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config,
|
||||
write_settings, write_system_prompt, run_turn, drive_turn,
|
||||
emit_turn_end, wait_for_login, compact_session}` so the two binaries
|
||||
can't drift.
|
||||
|
||||
## MCP surface
|
||||
|
||||
The harness ships an embedded MCP server (rmcp 1.7). Claude launches
|
||||
it as a stdio child via `--mcp-config`. The hyperhive socket name is
|
||||
`hyperhive`, so the tools land in claude as `mcp__hyperhive__<tool>`.
|
||||
|
||||
### Sub-agent tools
|
||||
|
||||
- `send(to, body, in_reply_to?)` — message a peer (logical agent
|
||||
name), another agent, or the operator (recipient `operator`,
|
||||
surfaces in the dashboard inbox). Optional `in_reply_to: i64`
|
||||
links this message to a prior message id for thread rendering
|
||||
in the dashboard message flow and the per-agent inbox.
|
||||
- `recv(wait_seconds?, max?)` — drain inbox messages. Without
|
||||
`wait_seconds` (or with `0`) returns immediately, a cheap
|
||||
"anything pending?" peek. Positive value parks the turn up
|
||||
to that many seconds (cap 180) — incoming messages wake
|
||||
instantly, otherwise returns empty at the timeout. `max`
|
||||
(default 1, server-side cap 32) drains up to N popped rows
|
||||
in one round-trip; `wait_seconds` applies to the *first*
|
||||
message, then the call drains up to `max` total.
|
||||
- `ask(question, options?, multi?, ttl_seconds?, to?)` —
|
||||
surface a structured question. Same shape as the manager's;
|
||||
recipient defaults to the operator (dashboard) but can be set
|
||||
to a peer agent name via `to: "<agent>"`. Answer routes back
|
||||
to the asker's own inbox as `HelperEvent::QuestionAnswered`
|
||||
via `coord.notify_agent`. For peer questions the recipient
|
||||
sees a `HelperEvent::QuestionAsked` event and replies with
|
||||
`answer(id, answer)`.
|
||||
- `answer(id, answer)` — respond to a `question_asked` event
|
||||
routed to this agent. Authorisation is strict: only the
|
||||
declared target (or the operator via the dashboard) can
|
||||
answer.
|
||||
- `get_loose_ends()` — list everything still pending against
|
||||
this agent: unanswered questions it asked / was asked, plus
|
||||
reminders it scheduled. Each row carries an id + kind for
|
||||
`cancel_loose_end`.
|
||||
- `cancel_loose_end(kind, id)` — withdraw a `question`
|
||||
(posts `[cancelled by <self>]` to unblock the asker), a
|
||||
`reminder` (hard-delete before fire), or (manager only) an
|
||||
`approval` (transitions to `Cancelled`; sub-agents refused with a
|
||||
clear error). Sub-agents may only cancel rows they own.
|
||||
- `remind(message, due)` — schedule a reminder that lands in
|
||||
this agent's own inbox at a future time (sender shows as
|
||||
`reminder`). Large payloads spill to
|
||||
`/agents/<self>/state/reminders/` with the inbox message a
|
||||
short pointer. Each agent's pending-reminder count is capped
|
||||
(default 50, override via `HIVE_REMIND_MAX_PENDING_PER_AGENT`);
|
||||
scheduling a new one fails if the cap is already hit.
|
||||
- `set_status(text)` — set a free-text status string visible on
|
||||
the operator dashboard. Persisted to
|
||||
`{state_dir}/hyperhive-status`; survives harness restarts. Pass
|
||||
an empty string to clear.
|
||||
- `get_agent_meta(name?)` — fetch identity + status metadata for
|
||||
an agent: `{ name, role, hyperhive_rev, running, status_text,
|
||||
status_set_at }`. Pass `name` to query a peer (e.g. check
|
||||
whether a sub-agent is idle before sending it work). Omit
|
||||
`name` to get your own identity stamp — replaces the previous
|
||||
`whoami` tool. `running` is `true` when the container is up.
|
||||
When `running` is `false` the host clears `status_text` /
|
||||
`status_set_at` (they would be stale snapshots from before the
|
||||
container stopped) before serving the response. Status fields are
|
||||
also `None` when the target has never called `set_status` or
|
||||
has cleared it.
|
||||
- `request_next_turn()` — ask the harness to start another turn
|
||||
immediately after this one ends, even if the inbox is empty. Use for
|
||||
multi-turn tasks (long builds, sequential steps) where you want to
|
||||
continue without waiting for an external message. The next turn starts
|
||||
with `from: "self"` and `body: "continue"`. No-op if new inbox
|
||||
messages arrive before this turn ends. No args.
|
||||
|
||||
### Waking the agent from inside the container
|
||||
|
||||
External MCP servers (and any other in-container process) can
|
||||
inject a wake-up event into the agent's inbox via the per-agent
|
||||
socket at `/run/hive/mcp.sock`. Two equivalent paths:
|
||||
|
||||
- **Shell out to `hive-ag3nt wake --from <label> --body <text>`**
|
||||
(use `--body -` to read body from stdin). Already on the
|
||||
container's `PATH` since the harness binary is in
|
||||
`systemPackages`. Convenient for shell-script integrations.
|
||||
|
||||
- **Speak the wire protocol directly** — JSON-line over the
|
||||
unix socket: `{"cmd":"wake","from":"matrix","body":"new dm
|
||||
from @alice"}\n`. Same shape any other AgentRequest uses;
|
||||
see `hive-sh4re::AgentRequest::Wake`.
|
||||
|
||||
The wake event lands in the broker as `{from:<label>,
|
||||
to:<agent>, body}`, which wakes whatever `recv` call the
|
||||
harness is currently blocked on. Next turn fires with the
|
||||
wake prompt formed from that message — claude sees "from:
|
||||
matrix" (or whatever label) and reacts.
|
||||
|
||||
Identity = socket: anything that can connect to
|
||||
`/run/hive/mcp.sock` is implicitly trusted to inject these,
|
||||
which is fine because the bind-mount is the agent's own
|
||||
container only.
|
||||
|
||||
### Extra MCP servers (per-agent)
|
||||
|
||||
Each agent's NixOS config can declare additional MCP servers via
|
||||
`hyperhive.extraMcpServers.<key> = { command, args, env,
|
||||
allowedTools }`. The module writes the map to
|
||||
`/etc/hyperhive/extra-mcp.json`; the harness reads it at boot and
|
||||
merges every entry into `--mcp-config` (under `mcpServers.<key>`)
|
||||
and `--allowedTools` (as `mcp__<key>__<pattern>`). The agent's
|
||||
flake.nix forwards every flake input to `agent.nix` as the
|
||||
`flakeInputs` module arg, so external MCP-server flakes are pulled
|
||||
in by adding them to `inputs.*` and referenced as
|
||||
`flakeInputs.<name>.packages.${pkgs.system}.default` — the
|
||||
resolved sha lands in the agent's own `flake.lock` and rolls up to
|
||||
meta's.
|
||||
|
||||
### Manager tools (in addition to send/recv)
|
||||
|
||||
- `request_init_config(name, description?)` — first step of a
|
||||
two-step spawn. Queues an `InitConfig` approval (≤9 char name);
|
||||
on operator approve, hive-c0re seeds the proposed config repo
|
||||
with a default `agent.nix` template and sends the manager a
|
||||
`HelperEvent::ConfigReady { agent }`. The manager then edits
|
||||
`agent.nix`, commits the changes, and calls `request_apply_commit`
|
||||
with the commit sha — the first ApplyCommit on a freshly-init'd
|
||||
config creates the container. Fails if a proposed repo for this
|
||||
name already exists. (The pre-#442 path through a separate
|
||||
manager-side `request_spawn` was removed; operator can still
|
||||
direct-spawn an empty agent from the dashboard's `◆ R3QU3ST SP4WN`
|
||||
button which routes via `HostRequest::RequestSpawn`.)
|
||||
- `kill(name)` — graceful stop. No approval required.
|
||||
- `start(name)` — start a stopped sub-agent. No approval.
|
||||
- `restart(name)` — stop + start. No approval.
|
||||
- `update(name)` — rebuild (re-applies the current hyperhive flake
|
||||
+ agent.nix, restarts). No approval, idempotent. Manager calls
|
||||
this on receipt of a `needs_update` system event.
|
||||
- `request_apply_commit(agent, commit_ref)` — submit a config
|
||||
change for any agent (`hm1nd` for the manager's own config) for
|
||||
operator approval.
|
||||
- `request_update_meta_inputs(inputs?, description?)` — queue an
|
||||
approval to run `nix flake update [inputs...]` on the meta flake.
|
||||
Pass specific input names (e.g. `["bitburner-agent"]`) or omit
|
||||
for all. Returns immediately; lock update runs on operator
|
||||
approval. Does NOT trigger rebuilds — call `update(name)` on
|
||||
affected agents after approval resolves.
|
||||
- `ask(question, options?, multi?, ttl_seconds?, to?)` —
|
||||
surface a structured question to the operator (default) or a
|
||||
sub-agent (`to: "<agent>"`). Non-blocking — returns the
|
||||
queued question id; the answer arrives later as
|
||||
`HelperEvent::QuestionAnswered { id, question, answer,
|
||||
answerer }` in the asker's inbox. Options always render
|
||||
alongside a free-text fallback; `multi=true` renders options
|
||||
as checkboxes. `ttl_seconds` auto-cancels with answer
|
||||
`[expired]` (and `answerer: "ttl-watchdog"`) after the
|
||||
deadline (useful for time-sensitive decisions that become moot
|
||||
if no one has responded). The operator can also manually
|
||||
cancel with `[cancelled]` via the dashboard.
|
||||
- `answer(id, answer)` — respond to a `question_asked` event
|
||||
that was routed to the manager (a sub-agent did
|
||||
`ask(to: "manager", ...)`). Surfaces in the asker's inbox as
|
||||
the same `question_answered` event.
|
||||
- `get_logs(agent, lines?)` — fetch recent journal lines for a
|
||||
sub-agent container (diagnose MCP-registration failures,
|
||||
startup crashes, etc.). Pass the plain logical agent name;
|
||||
hive-c0re resolves the machine name (`h-<name>`, manager
|
||||
`hm1nd`). `lines` defaults to 50, host-capped at 500.
|
||||
- `request_schedule_prompt(targets, body, first_fire_at_unix, interval_seconds?, description?)` —
|
||||
queue an operator-approval for a scheduled prompt. On approve,
|
||||
`body` is fanned out to each `targets` agent at
|
||||
`first_fire_at_unix`; recurring if `interval_seconds` is set,
|
||||
one-shot otherwise. Even self-targeted schedules go through
|
||||
approval (use `remind` for unapproved self-wake). Long downtime
|
||||
fires once per recurring row on resume (catch-up clamp).
|
||||
- `edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)` —
|
||||
partial-update a schedule (#474/#478). Pass only the fields to
|
||||
change; absent fields are left alone. `targets_add` / `targets_remove`
|
||||
mutate the recipient list in the same transaction; re-adding a
|
||||
previously-cancelled target drops its tombstone + history (fresh
|
||||
start). Clearing a scalar (e.g. `interval_seconds: null`) flips
|
||||
recurring→one-shot. Refuses cancelled rows. Same authorization as
|
||||
`cancel_schedule`.
|
||||
- `cancel_schedule(id, targets?)` — cancel a schedule. Omit
|
||||
`targets` / pass empty to cancel the whole schedule; pass a list
|
||||
to cancel just those recipients (auto-cancels when every target
|
||||
is removed). Authorization: manager can cancel schedules it owns
|
||||
or any owned by a sub-agent in its topology subtree.
|
||||
- `fire_schedule_now(id)` — fire a scheduled prompt out of band.
|
||||
Runs the per-target fan-out once immediately. Recurring schedules
|
||||
keep their cadence (the manual fire is additive); one-shot
|
||||
schedules are consumed by the fire and cancelled afterwards. Same
|
||||
authorization rules as `cancel_schedule`.
|
||||
- `list_schedules()` — snapshot every schedule (active +
|
||||
cancelled-but-not-reaped): id, owner, body, per-target
|
||||
`last_fired_at` + `last_result`, `next_fire_at_unix`,
|
||||
`interval_seconds`. Use to look up an id before cancelling or to
|
||||
audit upcoming wake-ups across the swarm.
|
||||
- `remind` / `get_loose_ends` / `cancel_loose_end` / `set_status`
|
||||
/ `get_agent_meta` — same as the sub-agent tools above.
|
||||
`get_loose_ends` scopes to the manager's own items by default;
|
||||
pass `agent: "*"` for a hive-wide view, or `agent: "<name>"`
|
||||
to inspect one agent.
|
||||
`cancel_loose_end` may cancel any agent's row.
|
||||
|
||||
The boundary: lifecycle ops on *existing* sub-agents
|
||||
(`kill`/`start`/`restart`) are at the manager's discretion — no
|
||||
operator approval. Creating a new agent (`request_init_config` →
|
||||
`request_apply_commit` for the first sha) and changing any agent's
|
||||
config (`request_apply_commit`) still go through the approval queue.
|
||||
|
||||
### Authoritative state
|
||||
|
||||
`hive_ag3nt::events::Bus` carries the current turn-loop state in
|
||||
addition to the broadcast channel and the events history. Variants:
|
||||
|
||||
- `Idle` — sitting on `Recv` waiting for mail.
|
||||
- `Thinking` — `claude --print` is running for a turn.
|
||||
- `Compacting` — operator-triggered `/compact` is in flight.
|
||||
|
||||
The harness flips state at the relevant transitions
|
||||
(`set_state(Thinking)` before `drive_turn`, `set_state(Idle)`
|
||||
after; `set_state(Compacting)` around `compact_session`). Exposed
|
||||
via `/api/state.turn_state` + `turn_state_since` (unix seconds);
|
||||
the agent page renders this rather than deriving from SSE events.
|
||||
|
||||
### Tool envelope
|
||||
|
||||
`mcp::run_tool_envelope`: every MCP tool handler logs the request,
|
||||
runs the body, logs the result. Pre-/post-log only — the inbox
|
||||
status hint moved to the wake prompt + UI header.
|
||||
|
||||
### Tool whitelist (`mcp::ALLOWED_BUILTIN_TOOLS`)
|
||||
|
||||
- Allowed built-ins: `Bash`, `Edit`, `Glob`, `Grep`, `Read`, `Write`.
|
||||
- Denied by omission: `WebFetch`, `WebSearch`, `Task`,
|
||||
`NotebookEdit`, `TodoWrite`.
|
||||
- Allowed MCP tools: as listed above per flavor.
|
||||
|
||||
By default `Bash` is approved wholesale — any shell command runs
|
||||
without confirmation. To restrict an agent to specific command
|
||||
families, set `hyperhive.allowedBashPatterns` in its `agent.nix`:
|
||||
|
||||
```nix
|
||||
hyperhive.allowedBashPatterns = [ "git *" "ls *" ];
|
||||
```
|
||||
|
||||
The harness reads `/etc/hyperhive/bash-allow.json` and replaces
|
||||
`Bash` in `--allowedTools` with `Bash(git *)` + `Bash(ls *)` etc.
|
||||
Commands outside the pattern list require confirmation — which in
|
||||
`--print` mode means they will not run. An empty list (default) keeps
|
||||
the current wholesale `Bash` entry.
|
||||
821
docs/web-ui.md
Normal file
821
docs/web-ui.md
Normal file
|
|
@ -0,0 +1,821 @@
|
|||
# Web UI
|
||||
|
||||
Two web surfaces share the same skeleton: the dashboard (port 7000)
|
||||
and the per-agent UIs (manager on :8000, sub-agents on a hashed
|
||||
:8100-8999). Both are SPAs — `GET /` returns a static shell,
|
||||
`/api/state` returns JSON, JS renders. No full-page reloads.
|
||||
|
||||
## Shape (shared by both)
|
||||
|
||||
- `GET /` → `index.html` from the bundled frontend dist (see
|
||||
`frontend/`). Both binaries' routers declare their dynamic
|
||||
endpoints first and then `fallback_service(ServeDir::new(...))`
|
||||
pointed at `HIVE_STATIC_DIR` — anything not matched by an API or
|
||||
action route is served from the dist. Dashboard dist lives at
|
||||
`${frontend}/dashboard`; per-agent dist is the merged
|
||||
`hyperhive.frontend.mergedDist` (default agent dist + per-agent
|
||||
`extraFiles` overlay).
|
||||
- `GET /static/*` → bundled CSS + JS produced by esbuild
|
||||
(`frontend/packages/{dashboard,agent}/build.mjs`). Both pages
|
||||
pull the shared terminal pane + Catppuccin palette + typography
|
||||
from `@hive/shared` (was `hive-fr0nt`); the CSS bundle inlines
|
||||
`base.css` + `terminal.css` via esbuild's `@import` resolution.
|
||||
`terminal.js` exports `{ create, linkify }` as ES module
|
||||
members (no more `window.HiveTerminal` global outside the
|
||||
back-compat shim the IIFE bodies still use). The dashboard's
|
||||
`#msgflow` and the per-agent `#live` log are both backed by
|
||||
this terminal — sticky-bottom auto-scroll, "↓ N new" pill,
|
||||
history backfill, SSE plumbing all live there. Each page
|
||||
registers a kind→renderer map; unknown kinds fall through to
|
||||
a JSON-dump note row. Bare `http(s)://` URLs in row text are
|
||||
turned into clickable new-tab links by `linkify` (text-node
|
||||
based, no `innerHTML` — XSS-safe); markdown bodies get the
|
||||
same treatment via `marked`'s autolink (npm dep, replacing the
|
||||
vendored UMD bundle), with the rendered `<a>`s rewritten to
|
||||
`target="_blank"` (issue #233).
|
||||
- `GET /api/state` → JSON snapshot the JS app renders into the
|
||||
DOM. Includes a top-level `seq` (the dashboard event channel's
|
||||
high-water mark at the moment the snapshot was assembled);
|
||||
clients use it to dedupe their buffered SSE traffic against
|
||||
the snapshot (drop frames with `seq <= snapshot.seq`).
|
||||
- `GET /dashboard/stream` (dashboard) / `GET /events/stream`
|
||||
(per-agent) → `text/event-stream` SSE for live updates. The
|
||||
dashboard stream carries broker `Sent` / `Delivered` (mirrored
|
||||
by a forwarder task from the broker's intra-process channel)
|
||||
plus mutation events (`approval_added` / `approval_resolved`,
|
||||
`question_added` / `question_resolved`, `transient_set` /
|
||||
`transient_cleared`). Each frame carries a `seq`. The
|
||||
matching backfill endpoint is `GET /dashboard/history` (last
|
||||
~200 broker messages wrapped in `{ seq, events }`) on the
|
||||
dashboard and `GET /events/history` (last 2000 `LiveEvent`s
|
||||
also wrapped in `{ seq, events }`) on the agent.
|
||||
**SSE multiplexing** (#448): the dashboard uses a
|
||||
`SharedWorker` (`stream-worker.js`) to hold one upstream
|
||||
`EventSource` per URL. All same-origin tabs share this worker
|
||||
— a second dashboard tab joins the existing connection rather
|
||||
than opening a duplicate. The worker fans SSE events out to
|
||||
each subscribed tab via `MessagePort`; on bfcache restore the
|
||||
page re-subscribes (gets a synthetic `open` event immediately
|
||||
if the upstream is already connected). Falls back gracefully
|
||||
when `SharedWorker` is unavailable (e.g. some private-mode
|
||||
browsers).
|
||||
|
||||
The JS app handles all `form[data-async]` submissions via a delegated
|
||||
listener: read `data-confirm`, swap the button to a spinner, POST
|
||||
`application/x-www-form-urlencoded`, re-enable the button on success
|
||||
(refreshState may keep the form mounted, so we don't rely on a
|
||||
re-render), call `refreshState()`. State shapes live in
|
||||
`dashboard.rs::StateSnapshot` and `web_ui.rs::StateSnapshot` — when
|
||||
adding state fields, plumb through the snapshot struct and the
|
||||
relevant `assets/tabs.js` render function.
|
||||
|
||||
**Focus preservation:** `refreshState` checks whether
|
||||
`document.activeElement` sits inside one of the managed sections
|
||||
and, if so, skips the refresh (defers 2s). The operator never has
|
||||
the form yanked out from under them mid-type; the update lands as
|
||||
soon as they blur.
|
||||
|
||||
**`<details>` open-state preservation:** any collapsible element
|
||||
tagged with `data-restore-key="<stable-key>"` survives the
|
||||
refresh. `snapshotOpenDetails()` walks managed sections before
|
||||
render, `restoreOpenDetails()` re-applies after. Long-content
|
||||
drill-ins (file previews, diffs, journald logs) now open in the
|
||||
**side panel** (see below) rather than expanding inline, so the
|
||||
only restore-keyed `<details>` left is the answered-questions
|
||||
history list.
|
||||
|
||||
**Side panel (dashboard):** long content opens in a drawer that
|
||||
swipes in from the right — a singleton `#side-panel` with a
|
||||
titled header, a close button, and a scrollable body. Closes on
|
||||
the button, a backdrop click, or `Escape`. `Panel.open(title,
|
||||
node)` swaps the body; the JS builders for file previews,
|
||||
approval diffs, and journald logs all render into it. **The
|
||||
drawer width is drag-to-resize** (#451): a thin 6px hit-strip on
|
||||
the left edge captures pointer events, resizes the drawer in
|
||||
real-time (pointer capture keeps dragging even if the cursor
|
||||
outpaces the handle), and persists the chosen width to
|
||||
`localStorage` (key `hyperhive:side-panel-width`) so it
|
||||
survives page reload. Width is clamped to CSS `min-width: 320px`
|
||||
/ `max-width: 96vw`; the viewport-resize handler re-clamps
|
||||
persisted values after a window shrink. File
|
||||
previews are type-aware:
|
||||
|
||||
- **Markdown** (`.md` / `.markdown`) — a `rendered` / `plain`
|
||||
tabbed view: `rendered` (default) is the vendored `marked`
|
||||
bundle (`GET /static/marked.js`), `plain` is the raw source.
|
||||
- **SVG** (`.svg`) — a `rendered` / `source` tabbed view;
|
||||
`rendered` shows the image via an `<img>` `data:` URI (the
|
||||
browser's secure static mode, so an untrusted SVG can't run
|
||||
scripts), `source` shows the raw markup.
|
||||
- **Raster images** (`.png` / `.jpg` / `.gif` / `.webp` /
|
||||
`.bmp` / `.ico` / `.avif`) — render as an `<img>` pointed at
|
||||
`/api/state-file`, which serves them as binary with their
|
||||
real content-type (text files stay UTF-8-lossy `text/plain`).
|
||||
- **Everything else** — raw text in a `<pre>`.
|
||||
|
||||
Both bind their listeners with `SO_REUSEADDR` via
|
||||
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries,
|
||||
exponential backoff capped at 2s) so an nspawn restart that races
|
||||
the previous process's socket release resolves itself.
|
||||
|
||||
## Dashboard layout
|
||||
|
||||
The dashboard (`/`) has a fixed chrome header at the top and a
|
||||
`<main>` that shows exactly one tab pane at a time. The URL hash
|
||||
(`#swarm`, `#call`, `#system`, `#schedules`) drives which pane is
|
||||
active; hash changes don't reload the page. FL0W is a separate
|
||||
full-page terminal at `/flow.html` — its tab-strip entry is a
|
||||
cross-page link (`◆ FL0W ◆ →`), not a pane swap.
|
||||
|
||||
**Chrome header** (fixed, overlays the active tab pane):
|
||||
- **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ SYST3M ◆`,
|
||||
`◆ SCH3DUL3S ◆`, and `◆ FL0W ◆ →` (page link). Count pills on
|
||||
SW4RM (container count), Y3R C4LL (pending approvals +
|
||||
questions), and SCH3DUL3S (active schedules); FL0W pill mirrors
|
||||
the operator inbox length (hidden when zero).
|
||||
- **Notification controls**: `🔔 enable notifications` when
|
||||
permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted.
|
||||
Always visible in the chrome regardless of active tab.
|
||||
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
|
||||
— sits below the tab strip.
|
||||
|
||||
### SW4RM tab
|
||||
|
||||
**C0NTAINERS** — live containers rendered as a depth-first
|
||||
tree using `ContainerView.parent` (populated by `topology.rs`).
|
||||
Each container's row is prefixed with ASCII tree glyphs (`├─`,
|
||||
`└─`, `│ ` continuation columns) showing the agent
|
||||
parent/child hierarchy. When every container has `parent = null`
|
||||
(flat topology) the tree collapses to a plain list with no
|
||||
glyphs. Children are sorted alphabetically within each parent;
|
||||
roots likewise. Cycles in the parent graph are tolerated —
|
||||
orphaned containers (not reachable from any root) are appended
|
||||
as roots so no agent disappears. Pulsing red banner at the top
|
||||
of this section if any two sub-agents hash to the same port
|
||||
(`port_conflicts` from `/api/state`): the operator must rename
|
||||
one of them and rebuild. `lifecycle::{spawn,rebuild}` also
|
||||
preflight this and refuse with a clear error message naming the
|
||||
conflicting agent.
|
||||
|
||||
`↻ UPD4TE 4LL` button appears above the containers list when any
|
||||
agent is stale.
|
||||
|
||||
### Y3R C4LL tab
|
||||
|
||||
Things blocked on operator decision — approvals and questions
|
||||
share a tab because they're the same concept ("something is
|
||||
waiting on you").
|
||||
|
||||
**P3NDING APPR0VALS** — the queue (see "Approval card" below).
|
||||
The R3QU3ST SP4WN form lives at the top of this section.
|
||||
|
||||
**M1ND H4S QU3STI0NS** — pending operator-targeted `ask`
|
||||
questions (amber pulsing border). Free-text fallback always
|
||||
rendered alongside any option list; `multi=true` renders options
|
||||
as checkboxes; submit merges selections + free text
|
||||
comma-joined. Each row has a `✗ CANC3L` button. Questions with
|
||||
a `ttl_seconds` show a `⏳ MM:SS` chip; the host-side watchdog
|
||||
auto-cancels with `[expired]` when the deadline fires.
|
||||
|
||||
### SYST3M tab
|
||||
|
||||
Passive / rare-interaction state.
|
||||
|
||||
**M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can
|
||||
selectively `nix flake update`, rendered as an indented tree:
|
||||
every fetched input at every depth (`hyperhive`,
|
||||
`hyperhive/nixpkgs`, `agent-<n>`, `agent-<n>/mcp-<x>`, …), each
|
||||
shown once at its shallowest path. `read_meta_inputs` walks the
|
||||
lock graph with a `visited` set — `follows` aliases and rev-less
|
||||
nodes are skipped (issue #275). A `select all / select none`
|
||||
control sits above the tree. Checking inputs + submitting bumps
|
||||
the lock in `/meta/` and rebuilds the selected agents in
|
||||
sequence; each outcome reaches the manager as a `rebuilt`
|
||||
system event. `POST /meta-update`. While a lock-bump ripple runs,
|
||||
the panel shows a pulsing "⏳ meta-update running" banner and the
|
||||
update button is disabled (snapshot field `meta_update_running`,
|
||||
live event `meta_update_running`).
|
||||
|
||||
**R3BU1LD QU3U3** — pending and recently-completed container
|
||||
operations: rebuilds, meta-update cascades, and first-spawns.
|
||||
One operation runs at a time; the worker drains FIFO. Each row
|
||||
shows a state glyph (`⏸` queued / `▶` running / `✔` done /
|
||||
`✖` failed / `⊘` cancelled), kind glyph + verb (`↻ rebuild`,
|
||||
`◆ meta_update`, `✨ spawn`, `🗑 destroy`), agent name, source
|
||||
chip (`manual | meta_update | auto_update | crash_recover | approval`
|
||||
— green for operator-approved config changes; #436),
|
||||
timing, and an optional reason / error. Meta-update cascade
|
||||
rebuilds nest under their parent entry (`parent_id` grouping;
|
||||
`rqe-child` CSS class). Dedup: re-enqueueing a still-queued op
|
||||
for the same agent collapses into the existing entry. Running
|
||||
entries tick elapsed seconds live, and when the worker has
|
||||
annotated the current phase (#437) a cyan `↳ <step>` sub-line
|
||||
appears under the main row showing the in-flight step name
|
||||
(e.g. `↳ meta prepare_deploy` → `↳ nixos-container update` →
|
||||
`↳ finalize deploy`). Terminal transitions clear `step` on the
|
||||
backend so Done / Failed rows don't render stale labels.
|
||||
Cold-loaded from `/api/state.rebuild_queue`; live updates via
|
||||
`rebuild_queue_changed` snapshot event.
|
||||
|
||||
**K3PT ST4T3** — destroyed-but-state-kept tombstones (size +
|
||||
age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a
|
||||
Spawn approval; existing state is reused), `PURG3` (wipes
|
||||
state + applied dirs; `POST /purge-tombstone/{name}`).
|
||||
|
||||
### SCH3DUL3S tab
|
||||
|
||||
Anything that fires at a future time. Operator-set schedules
|
||||
go through the creation form at the top; agent self-paced
|
||||
reminders surface at the bottom as a sibling list (#460 —
|
||||
they share enough conceptual ground to live together).
|
||||
|
||||
**N3W SCH3DUL3 / QU3U3D SCH3DUL3S** — operator-managed
|
||||
scheduled prompts (#444 / #459). Lists every schedule with
|
||||
its description, targets, body, recurrence interval, next-fire
|
||||
time, and per-target last-result. Per-row controls: a
|
||||
`↯ fire now` button sends an out-of-band manual pulse to
|
||||
every active target (#467 — recurring schedules keep their
|
||||
cadence; one-shots are consumed after the manual fire), an
|
||||
`✎ edit` button opens an inline edit form (#474 — body /
|
||||
description / interval / next-fire / targets all editable;
|
||||
targets are a multi-select diff'd against the original active
|
||||
set so unchecked-was-active = `targets_remove`, checked-not-
|
||||
originally-active = `targets_add`; submit PATCHes
|
||||
`/api/schedules/{id}`), and a
|
||||
`CANC3L` button cancels the whole schedule
|
||||
(`POST /api/schedules/{id}/cancel`). Individual target chips
|
||||
have their own cancel links. An inline creation form lets
|
||||
the operator queue a new schedule directly:
|
||||
targets (multi-select checkboxes drawn from live container
|
||||
names + `operator` + `manager`), prompt body (textarea),
|
||||
first-fire datetime-local (pre-filled to 5 minutes from now),
|
||||
an interval composer (#466 — preset chips for common
|
||||
durations + separate d/h/m/s number fields with a live
|
||||
"↻ every …" preview; all-zero = one-shot),
|
||||
and an optional human-readable description. On submit the
|
||||
form POSTs to `/api/schedules` as JSON; the tab pill shows
|
||||
the count of active schedules (at least one live target not
|
||||
yet cancelled). Refreshed on tab activation and after each
|
||||
submit/cancel. Backed by `GET /api/schedules`.
|
||||
|
||||
**QU3U3D R3M1ND3RS** — reminders agents have scheduled for
|
||||
themselves (via the `remind` tool) but not yet delivered.
|
||||
Each row shows the owner, due time, and message; a `CANC3L`
|
||||
button hard-deletes (`POST /cancel-reminder/{id}`) and a
|
||||
`R3TRY` button re-arms one whose delivery failed
|
||||
(`POST /retry-reminder/{id}`). Backed by `GET /api/reminders`.
|
||||
Lives in the SCH3DUL3S tab alongside operator schedules so the
|
||||
operator has one place for everything time-fired (#460).
|
||||
|
||||
### FL0W page (`/flow.html`)
|
||||
|
||||
A dedicated full-page terminal (not a tab pane — a separate HTML
|
||||
page). Reuses the same `<header class="dashboard-chrome">` chrome
|
||||
as the dashboard so the tab strip remains visible; SW4RM / Y3R
|
||||
C4LL / SYST3M / SCH3DUL3S are cross-page links back to `/#<tab>`,
|
||||
and the FL0W entry is marked active (`aria-current="page"`).
|
||||
|
||||
**0PER4T0R 1NB0X** — recent messages addressed to `operator`,
|
||||
derived client-side from the dashboard event stream. Cold load
|
||||
seeds from `/dashboard/history`'s 200-message backfill; subsequent
|
||||
`sent` events with `to == "operator"` are appended live. Cap 50,
|
||||
newest-first.
|
||||
|
||||
**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
|
||||
Cold load backfills the last ~200 messages from `/dashboard/history`;
|
||||
live frames arrive on `/dashboard/stream`. Each row is one broker
|
||||
event — `sent` or `delivered` — with `from → to: body`. Sticky-
|
||||
bottom auto-scroll + "↓ N new" pill. Below the stream sits a
|
||||
terminal-style compose box: `@name` picks the recipient (sticky via
|
||||
localStorage; auto-complete from the live container list, Tab/Enter
|
||||
to confirm; `@*` broadcasts). `POST /op-send` drops
|
||||
`{from:"operator", to, body}` into the broker; the resulting SSE
|
||||
frame re-renders both the terminal row and the inbox section.
|
||||
Manager is addressed as `@manager` (the broker recipient string),
|
||||
not `@hm1nd` (the container name).
|
||||
|
||||
### Container row
|
||||
|
||||
A full-height **square agent icon** (5em, capped) on the left. The
|
||||
icon is the **selection toggle**: click (or Enter/Space) adds/removes
|
||||
the agent from the selection set; `aria-pressed` reflects the state;
|
||||
the tooltip says "select … for bulk actions" or "deselect … (or press
|
||||
Esc to clear all)". The `<img>` points at `<url>/icon`; load failure
|
||||
falls back to the dimmed hyperhive mark (`/favicon.svg`). The card
|
||||
body sits to the right with three stacked lines
|
||||
(`assets/tabs.js::renderContainers`).
|
||||
|
||||
- Line 1: agent name (link → new tab), m1nd/ag3nt chip, an
|
||||
**icon-only nav strip** populated async from the agent backend
|
||||
(`📊 stats`, `🖥 screen` when GUI is enabled, `⬡ forge profile`,
|
||||
`↳ agent-configs mirror`, plus any agent-declared
|
||||
`dashboardLinks` extras — issue #262). The dashboard JS fetches
|
||||
`GET /api/agent/{name}/links`, a same-origin passthrough proxy
|
||||
that forwards the agent's own link list; the agent backend is
|
||||
the single source of truth. The frontend resolves each
|
||||
`AgentLink.kind` (`container` → `http://host:<container.port>`,
|
||||
`forge` → `http://host:3000`, `external` → already absolute).
|
||||
**When the container is stopped** (`ContainerView.running = false`),
|
||||
the host clears live-only fields before emitting the state, so
|
||||
the dashboard never renders stale data: the badge chain is
|
||||
replaced by a single muted `■ not running` badge, the nav-strip
|
||||
fetch is skipped (the agent web server is down), and the
|
||||
self-reported status text is suppressed. The agent icon goes
|
||||
straight to the dimmed `/favicon.svg` fallback instead of
|
||||
attempting a doomed load from the container's URL. Static fields
|
||||
— `needs_update`, `deployed_sha`, `pending_reminders`, `parent`,
|
||||
`config` link — remain visible regardless of run state.
|
||||
When the container is running, status badges follow — `⊘ rate
|
||||
limited` (red, while the harness is parked after a 429), `needs
|
||||
login`, `needs update` — in-flight `◐ pending-state…` pill
|
||||
(replaces buttons during operator-initiated start / stop /
|
||||
restart / rebuild / destroy). Additionally, when a rebuild-queue
|
||||
entry for this agent is `queued` or `running` but no
|
||||
operator-initiated transient is set, the card surfaces a
|
||||
`building…` / `meta-updating…` badge sourced from
|
||||
`rebuildQueueState` (#398) — so the SW4RM tab shows the same
|
||||
rebuild progress visible on the SYST3M tab's R3BU1LD QU3U3.
|
||||
Container name + port, and a `ctx · Nk` chip showing the
|
||||
agent's last-turn context size (from `ContainerView.ctx_tokens`,
|
||||
read from the turn-stats sqlite on each `build_all` sweep;
|
||||
absent until the first turn). The chip colour (green / yellow /
|
||||
red) is keyed off the model's real context window: `build_all`
|
||||
resolves the last turn's model against the host's per-model
|
||||
`contextWindowTokens` config and exposes it as
|
||||
`ContainerView.context_window_tokens`; the badge goes yellow
|
||||
≥ 50% and red ≥ 75% of that window (the harness compaction
|
||||
watermarks). When the window can't be resolved the badge falls
|
||||
back to fixed 100k / 150k thresholds. (issue #66)
|
||||
- Line 2: status badges only (no per-card action buttons — actions
|
||||
moved to the **selection bar**, see below).
|
||||
- Line 3: drill-in triggers —
|
||||
- `↳ logs · <container>` — opens the side panel and lazy-
|
||||
fetches journald via `GET /api/journal/{name}?unit=&lines=`
|
||||
(`journalctl -M <container> -b --no-pager --output=short-iso`).
|
||||
A unit dropdown (harness service / full machine journal) and
|
||||
a refresh button live in the panel.
|
||||
- Plain navigation links (config repo, forge profile,
|
||||
`dashboardLinks` extras) now live in the icon-only nav strip
|
||||
on Line 1 — see above (issue #262). The agent's `config` link
|
||||
goes to the repo root; the deployed sha shows separately on
|
||||
Line 1 as the `deployed:<sha>` chip, since the agent harness
|
||||
can't know its own deployed commit.
|
||||
|
||||
`↻ UPD4TE 4LL` button appears above the containers list when any
|
||||
agent is stale. Banner pulses on each broker SSE event
|
||||
(`pulseBanner` with a 4s grace timer).
|
||||
|
||||
### Selection bar
|
||||
|
||||
When one or more agents are selected (via icon click), a sticky
|
||||
frosted-mauve bar slides up from the bottom of the viewport
|
||||
(`#selection-bar`, `position: fixed; bottom: 0`). It shows:
|
||||
|
||||
- **Count + names** — "N agents selected · name1, name2, …"
|
||||
- **Bulk action buttons** — only enabled when ALL selected agents
|
||||
support the action; disabled with a tooltip naming the blockers
|
||||
when the selection is mixed:
|
||||
- `↺ R3ST4RT` — running agents only
|
||||
- `■ ST0P` — running agents only (manager included; no special-case)
|
||||
- `▶ ST4RT` — stopped agents only
|
||||
- `↻ R3BU1LD` — always available
|
||||
- `DESTR0Y` / `PURG3` — sub-agents only (disabled if manager selected)
|
||||
- **`✕ clear`** button + `Esc` key clear the entire selection.
|
||||
|
||||
Stale selections (agents destroyed while selected) are pruned on
|
||||
every render before the bar appears.
|
||||
|
||||
### Approval card
|
||||
|
||||
Each pending approval renders as a card (`assets/tabs.js::
|
||||
renderApprovals`) with three stacked sections:
|
||||
|
||||
- **identity header** — glyph, `#id`, agent, kind chip, (for
|
||||
`apply_commit`) the short proposal sha as `<code>`, and a
|
||||
right-aligned `requested <N> ago` relative time from
|
||||
`ApprovalView.requested_at` — amber once the request has been
|
||||
pending ≥ 1h so a stale approval stands out (issue #272).
|
||||
- **what-changed body** — the manager's description, then
|
||||
drill-in triggers: `↳ view diff` opens the diff in the side
|
||||
panel; `↳ commit on forge ↗` deep-links the proposal commit
|
||||
into `agent-configs/<agent>` (shown only when `forge_present`).
|
||||
Spawn approvals show a one-line "container will be created"
|
||||
note instead.
|
||||
- **decision actions** — `◆ APPR0VE` and `DENY`. Deny pops a
|
||||
`prompt()` for an optional reason carried to the manager as
|
||||
`HelperEvent::ApprovalResolved.note`.
|
||||
|
||||
The diff panel has a 3-way base toggle — **vs applied** (the
|
||||
running tree, served instantly from the diff already on the
|
||||
approval), **vs last-approved**, **vs previous proposal** — the
|
||||
latter two fetched on click from `GET /api/approval-diff/{id}
|
||||
?base=approved|previous`. Each line is classified client-side
|
||||
(`+` / `-` / `@@` / `--- ` / `+++ ` → add / del / hunk / file).
|
||||
|
||||
A `pending · N` / `history · N` tab pair switches the section
|
||||
between the live queue and the last 30 resolved approvals.
|
||||
|
||||
### Browser notifications
|
||||
|
||||
Pure frontend (`Notification` API). Three signals trigger them:
|
||||
|
||||
- new pending approval (per id, delta on `/api/state`)
|
||||
- new pending operator question (per id)
|
||||
- new broker message sent `to: "operator"` (live via SSE)
|
||||
|
||||
First `/api/state` after page load seeds "seen" sets without
|
||||
firing — only items that arrive while the page is open count.
|
||||
Per-event tags (`hyperhive:approval:<id>`, `hyperhive:question:<id>`,
|
||||
`hyperhive:msg:<at>:<rand>`) so distinct events stack in the OS
|
||||
notification center instead of overwriting each other.
|
||||
`console.debug` logs at every block point (unsupported,
|
||||
permission ungranted, muted) for in-browser debugging. Click
|
||||
focuses the dashboard tab. localStorage-backed mute toggle
|
||||
silences without revoking the OS permission. Requires a secure
|
||||
context (HTTPS or localhost); on other origins the controls hide
|
||||
themselves. Browsers typically suppress notifications while the
|
||||
originating tab is focused — that's a browser-level decision,
|
||||
not ours.
|
||||
|
||||
### Dashboard endpoints
|
||||
|
||||
- `POST /approve/{id}` — approve a pending approval. Fires
|
||||
`ApprovalResolved` on the dashboard event channel; client
|
||||
updates derived approvals state from the event.
|
||||
- `POST /deny/{id}` (`note=<reason>`, optional) — deny a pending
|
||||
approval with an optional operator-supplied reason. The reason
|
||||
travels to the manager as `HelperEvent::ApprovalResolved.note`
|
||||
and also rides on the dashboard's `ApprovalResolved` event.
|
||||
Dashboard prompts via `window.prompt()` on click.
|
||||
- `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle.
|
||||
`destroy` accepts `purge=on` to also wipe state dirs.
|
||||
- `POST /purge-tombstone/{name}` — wipe a tombstone's state dirs.
|
||||
- `POST /answer-question/{id}` — answer a pending operator question.
|
||||
- `POST /cancel-question/{id}` — cancel a pending question with
|
||||
the sentinel `[cancelled]`. Same code path as a real answer.
|
||||
- `POST /request-spawn` — queue a Spawn approval.
|
||||
- `POST /update-all` — rebuild every stale container.
|
||||
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry
|
||||
(#447). Refuses `Running` / terminal-state entries (in-flight
|
||||
rebuilds can't be safely interrupted). Always 200; body is
|
||||
`{"cancelled": true}` on a successful flip or
|
||||
`{"cancelled": false}` when the entry was not in `Queued` state.
|
||||
- `POST /op-send` (`to=<name>`, `body=<text>`) — drop an
|
||||
operator-authored message into `<name>`'s inbox. `to=*` fans
|
||||
out to every registered agent. Returns 200; the broker
|
||||
`Sent` event re-renders both the message-flow terminal and
|
||||
the operator inbox without a snapshot refetch. Used by the
|
||||
compose textbox under MESS4GE FL0W.
|
||||
- `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for
|
||||
a managed container; rendered in the side panel.
|
||||
- `GET /api/approval-diff/{id}?base=applied|approved|previous` —
|
||||
on-demand unified diff for an `ApplyCommit` approval against
|
||||
the chosen base (running tree / last approved proposal /
|
||||
previous queued proposal). Raw diff text, classified
|
||||
client-side. `GET /static/marked.js` serves the vendored
|
||||
`marked` bundle the side panel uses for markdown previews.
|
||||
- `GET /api/state-file?path=<host-or-container-path>` — bounded
|
||||
text read of a file under the per-agent `state/` subtree or
|
||||
the shared `/var/lib/hyperhive/shared/`. Accepts the
|
||||
container-view forms (`/agents/<n>/state/...`, `/shared/...`)
|
||||
and the host form. Canonicalises + verifies the path stays
|
||||
inside the allow-list, refuses anything but a regular file,
|
||||
refuses `/agents/<n>/claude` / `config` subtrees, truncates
|
||||
bodies at 1 MiB. Click-time backing for the inline path-link
|
||||
preview.
|
||||
|
||||
Detection of which tokens *are* path links is done
|
||||
**server-side at broker-message ingest**, not client-side:
|
||||
the broker forwarder calls `scan_validated_paths(body)` —
|
||||
same allow-list helper the read endpoint uses — and attaches
|
||||
the verified file tokens to the event as `file_refs: Vec<String>`.
|
||||
The client trusts that list and linkifies only those tokens,
|
||||
so directories, missing files, and forbidden subtrees never
|
||||
become anchors. No probe endpoint, no client-side regex
|
||||
heuristics. Historical messages get the same treatment on
|
||||
`/dashboard/history` backfill.
|
||||
- `GET /api/reminders` — list pending reminders for the
|
||||
dashboard's queued-reminders panel.
|
||||
- `POST /cancel-reminder/{id}` — hard-delete a pending reminder.
|
||||
- `POST /retry-reminder/{id}` — re-arm a reminder whose delivery
|
||||
failed (clears the failure state so the scheduler retries).
|
||||
- `GET /api/schedules` — list all schedules (active and
|
||||
recently cancelled) for the SYST3M scheduled-prompts panel.
|
||||
- `POST /api/schedules` — operator-direct schedule create:
|
||||
`{ targets, body, first_fire_at_unix, interval_seconds?, description? }`.
|
||||
Agent-initiated schedules go through the approval queue instead
|
||||
(manager MCP `request_schedule_prompt`).
|
||||
- `PATCH /api/schedules/{id}` — partial edit (#474). JSON body
|
||||
`{ body?, description?, interval_seconds?, next_fire_at_unix?,
|
||||
targets_add?, targets_remove? }`.
|
||||
Missing key = "leave alone"; explicit `null` on
|
||||
`description` / `interval_seconds` clears the field (so a
|
||||
recurring schedule flips to one-shot when `interval_seconds`
|
||||
is sent as `null`). `targets_add` is replace-on-conflict:
|
||||
re-adding a previously-cancelled target drops the tombstone
|
||||
and the target starts fresh (operator intent on re-add =
|
||||
"this target is active again"). `targets_remove` delegates
|
||||
to the same path as `cancel_targets` — tombstones preserve
|
||||
audit, parent schedule auto-cancels when no active targets
|
||||
remain. Refuses cancelled rows; returns the updated
|
||||
`WireSchedule` on success.
|
||||
- `POST /api/schedules/{id}/cancel` — cancel a schedule. Body
|
||||
`{ targets?: ["name", …] }` cancels just those recipients;
|
||||
absent or empty body cancels the whole schedule.
|
||||
- `POST /api/schedules/{id}/fire-now` — out-of-band manual
|
||||
pulse (#467). Fires the schedule body once immediately to
|
||||
every active target. Recurring schedules: `next_fire_at_unix`
|
||||
is untouched; the regular cadence continues. One-shots: the
|
||||
schedule is consumed (cancelled) after the manual fan-out.
|
||||
Per-target `last_result` is annotated as a manual fire so
|
||||
the audit trail distinguishes scheduled fires from operator-
|
||||
triggered ones.
|
||||
- `POST /meta-update` — `nix flake update` the selected
|
||||
`meta/flake.lock` inputs, then rebuild the affected agents.
|
||||
- `GET /dashboard/stream` — unified live event channel:
|
||||
broker `sent` / `delivered`, plus the mutation events listed
|
||||
below. Each frame carries `seq`.
|
||||
- `GET /dashboard/history` — last ~200 broker messages
|
||||
(wrapped as `{ seq, events }`) for the message-flow
|
||||
terminal's backfill on page load.
|
||||
|
||||
### Dashboard event channel
|
||||
|
||||
Wire vocabulary on `/dashboard/stream` (kind tag is in the JSON
|
||||
payload):
|
||||
|
||||
- `sent` / `delivered` — broker traffic, mirrored from the
|
||||
intra-process channel by a forwarder task. Both carry `id: i64`
|
||||
(the broker row id) and `in_reply_to: Option<i64>` for thread
|
||||
rendering. The dashboard message-flow terminal renders reply
|
||||
rows with a `↳ reply` tag that scroll-highlights the parent
|
||||
row on click. Used by the message-flow terminal renderer and
|
||||
the operator-inbox derived state.
|
||||
- `approval_added` (id, agent, approval_kind, sha_short, diff,
|
||||
description) / `approval_resolved` (id, agent, approval_kind,
|
||||
sha_short, status, resolved_at, note, description) — pending
|
||||
queue + history mutations. Client mutates a derived store and
|
||||
re-renders only the approvals section.
|
||||
- `question_added` (id, asker, question, options, multi,
|
||||
asked_at, deadline_at, target) / `question_resolved` (id,
|
||||
answer, answerer, answered_at, cancelled, target) — both
|
||||
operator-targeted and peer (agent-to-agent) threads fire
|
||||
these. The dashboard's questions pane surfaces both, with
|
||||
filter chips (all / @operator / @peer / per-participant) and
|
||||
an `0V3RR1D3` button on peer rows so the operator can
|
||||
answer when an agent is stuck. The ttl watchdog fires
|
||||
`question_resolved` with `answerer = "ttl-watchdog"` on
|
||||
expiry.
|
||||
- `transient_set` (name, transient_kind, since_unix) /
|
||||
`transient_cleared` (name) — lifecycle action spinners. The
|
||||
client ticks the elapsed-seconds badge off `since_unix`
|
||||
client-side, no polling.
|
||||
- `container_state_changed` (container: ContainerView) /
|
||||
`container_removed` (name) — per-row container mutations,
|
||||
emitted by `Coordinator::rescan_containers_and_emit` from
|
||||
every mutation site (`actions::approve` post-spawn,
|
||||
`actions::destroy`, the lifecycle_action wrapper,
|
||||
`auto_update::rebuild_agent`) and from the 10s
|
||||
`crash_watch` poll. Client upserts/removes by name; the
|
||||
pending overlay is read from `transientsState` since the
|
||||
payload doesn't carry it.
|
||||
- `rebuild_queue_changed` (seq, queue: `Vec<QueueEntry>`) —
|
||||
full snapshot of the rebuild queue on every mutation (enqueue,
|
||||
state transition, dedup collapse, terminal-history trim).
|
||||
Same snapshot-over-diff rationale as `tombstones_changed` /
|
||||
`meta_inputs_changed`: the list is small and the client's
|
||||
`parent_id` grouping is most naturally re-derived from the
|
||||
full list. Cold-loaded from `/api/state.rebuild_queue`.
|
||||
|
||||
`/api/state` is **only fetched on cold-load and on the few
|
||||
forms that mutate non-event-derived state** (PURG3 +
|
||||
meta-update, since tombstones + meta_inputs aren't event-
|
||||
shaped yet). Every other section — approvals, questions,
|
||||
transients, containers, operator inbox, message flow —
|
||||
derives from `/dashboard/stream` after the initial snapshot,
|
||||
maintaining its own client-side store and applying events on
|
||||
top. The 5s periodic poll is gone.
|
||||
|
||||
Generalised form helpers: `form[data-confirm="…"]` pops
|
||||
`confirm()` before submit; `form[data-prompt="…"]` pops
|
||||
`prompt()` and stashes the answer in a hidden input named by
|
||||
`data-prompt-field` (default `note`).
|
||||
|
||||
## Per-agent page
|
||||
|
||||
Three fixed-position layers frame a full-viewport terminal:
|
||||
|
||||
**Fixed-overlay header** (`<header class="agent-header">`): frosted
|
||||
glass — `backdrop-filter: blur` lets scrolled terminal rows show
|
||||
through. Three flex columns (#394 redesign):
|
||||
|
||||
- **Agent icon** (`<img class="agent-icon">`): fixed-size square
|
||||
identity anchor (5em, `width: 5em; aspect-ratio: 1;
|
||||
align-self: flex-start` — capped so a tall state-row doesn't
|
||||
inflate the icon, #411). Falls back to the dimmed hyperhive mark
|
||||
on load error.
|
||||
- **Main column** (`.agent-header-main`): two rows.
|
||||
- Row 1 (`.agent-header-title-row`): title (`<h2 id="title">`) +
|
||||
meta-nav (`<nav id="meta-links">`). Meta-nav renders
|
||||
backend-supplied `StateSnapshot.links` as icon-only anchors — always
|
||||
`📊 stats` (`kind = Container`); `🖥 screen` when VNC is enabled;
|
||||
`⬡ forge` (profile) + `↳ config` (agent-configs mirror) when the
|
||||
agent has a forge account; any `hyperhive.dashboardLinks` extras
|
||||
(`kind = External`). A `↑ dashboard` link is prepended by the JS
|
||||
so the host dashboard is one click away. `GET /api/agent/{name}/links`
|
||||
is the single source of truth.
|
||||
- Row 2 (`.agent-state-row`): alive badge + state badge + model chip
|
||||
+ ctx badge + cost badge + last-turn chip + cancel button.
|
||||
- Alive badge: `● alive` (green) / `⊘ rate limited` (red) /
|
||||
`◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`.
|
||||
Driven by `LiveEvent::StatusChanged`.
|
||||
- State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` /
|
||||
`○ offline` / `… booting` + age suffix. Driven by
|
||||
`LiveEvent::TurnStateChanged ({ state, since_unix })`.
|
||||
- Model chip: `model · <name>`. Driven by `LiveEvent::ModelChanged`.
|
||||
- Ctx badge: `ctx · 142k` — last inference's prompt size.
|
||||
Tooltip shows % of window when `context_window_tokens` is known.
|
||||
- Cost badge: `cost · 1.3M` — cumulative tokens billed across every
|
||||
inference in the last turn (tool-heavy turns rebill the cached
|
||||
prefix per call — cost signal, not size signal).
|
||||
- Both driven by `LiveEvent::TokenUsageChanged { ctx, cost }` at
|
||||
turn-end.
|
||||
- `■ cancel turn` (visible while thinking) → `POST /api/cancel`.
|
||||
- **Right cluster** (`.agent-header-pills`): flyout pills + overflow.
|
||||
- **Inbox pill** (`📬 inbox · N`): hidden when empty; click opens
|
||||
the inbox flyout in the side panel.
|
||||
- **Loose-ends pill** (`🪢 loose ends · N`): hidden when empty;
|
||||
click opens the loose-ends flyout.
|
||||
- **Overflow button** (`⋯`): always visible. Opens a frosted popover
|
||||
(`#overflow-menu`, positioned outside the header to escape any
|
||||
stacking context) with three rows: `↑ dashboard` (link), `↻ rebuild
|
||||
container` (POST confirm, same action as the dashboard R3BU1LD
|
||||
button), `↻ new claude session` (POST confirm → `POST
|
||||
/api/new-session`; next turn drops `--continue`). Both destructive
|
||||
actions require one extra click to acknowledge (#394 — rare ops
|
||||
shouldn't live in the primary state strip).
|
||||
|
||||
`/api/state` is fetched once on cold load (+ while
|
||||
`status === 'needs_login_in_progress'`); all other updates arrive via
|
||||
SSE. Snapshot includes `context_window_tokens` for the ctx badge tooltip.
|
||||
|
||||
**Main content** (`<main class="agent-main">`): fills the viewport
|
||||
and scrolls behind the fixed header + footer.
|
||||
- `#status` overlay: empty when online; shows the login form / OAuth
|
||||
URL when `status` is `needs_login_*`.
|
||||
- Terminal-wrap: live event tail (sticky-bottom auto-scroll +
|
||||
`↓ N new` pill when not at bottom).
|
||||
|
||||
**Fixed-overlay footer** (`<footer class="agent-composer">`): frosted
|
||||
glass, symmetric with the header. Contains the operator-input
|
||||
textarea (`#term-input`) — multi-line, Enter sends, Shift+Enter
|
||||
newlines, Tab-completes slash commands (see "Terminal-embedded
|
||||
prompt" below).
|
||||
|
||||
**Side panel** (slide-in from right): singleton shared with the
|
||||
dashboard's side panel shape. Carries inbox and loose-ends flyouts
|
||||
(opened via the header pills) as well as long content (file previews,
|
||||
diffs, journald logs). Inbox flyout: last 30 messages addressed to
|
||||
this agent (`AgentRequest::Recent { limit: 30 }`); reply messages
|
||||
indented with `↳ reply ·` in amber. Loose-ends flyout: questions,
|
||||
approvals, and reminders pending against this agent (`GET /api/loose-ends`);
|
||||
question rows carry an inline answer form that POSTs cross-origin to
|
||||
the core dashboard's `/answer-question/{id}` so the operator answers
|
||||
*as operator* (see `docs/boundary.md`).
|
||||
|
||||
### Live view
|
||||
|
||||
Each agent runs an `events::Bus`: a `tokio::sync::broadcast<LiveEvent>`
|
||||
plus a sqlite-backed history at `/state/hyperhive-events.sqlite`.
|
||||
The harness emits `TurnStart { from, body, unread }`,
|
||||
`Stream(value)` (one per parsed stream-json line), `Note`,
|
||||
`TurnEnd { ok, note }`. The web UI:
|
||||
|
||||
- fetches `GET /events/history` on page load and replays the last
|
||||
2000 events (oldest first, with `.no-anim` so they don't
|
||||
stagger);
|
||||
- then subscribes to `GET /events/stream` (SSE) for live tail;
|
||||
- shows a granular state badge above the terminal, driven
|
||||
authoritatively from `/api/state.turn_state`. SSE turn_start /
|
||||
turn_end still flip the badge instantly between renders;
|
||||
- sticky-bottom auto-scroll: scrolling up parks the view; new rows
|
||||
surface a "↓ N new" pill instead of yanking;
|
||||
- terminal-themed: phosphor mauve glow, Crust bg,
|
||||
backdrop-filter blur, row fade-in slide-up.
|
||||
|
||||
Per-stream rendering:
|
||||
|
||||
- `Stream` `tool_use` →
|
||||
- `Write` / `Edit`: collapsed `<details>` with a +/- diff body
|
||||
(`-` lines from `input.old_string`, `+` lines from
|
||||
`input.new_string` or every line of `input.content`).
|
||||
Summary carries the path + line counts.
|
||||
- others (`Read /path`, `Bash $ cmd`, `mcp__hyperhive__send →
|
||||
operator: "..."`, etc.): flat one-line per-tool format.
|
||||
- `Stream` `tool_result` short → flat `← ...`; long → collapsed
|
||||
`<details>` `▸ ← Nl · headline` (click to expand full body).
|
||||
- `Stream` `thinking` → text content if claude provided one,
|
||||
otherwise the bare `· thinking …` indicator.
|
||||
- `Stream` `system init`, `result`, `rate_limit_event` are
|
||||
dropped — too noisy.
|
||||
- `Note` → `· text`.
|
||||
- `TurnEnd` → `✓ turn ok` / `✗ turn fail — note`, triggers a
|
||||
`refreshState()`.
|
||||
|
||||
### Terminal-embedded prompt
|
||||
|
||||
The operator input lives *inside* the terminal-wrap as a
|
||||
prompt-style textarea below the live tail: multi-line (Enter
|
||||
sends, Shift+Enter newlines), tab-completes slash commands.
|
||||
|
||||
Slash commands today:
|
||||
|
||||
- `/help` — list commands locally.
|
||||
- `/clear` — wipe the local terminal view (server history kept).
|
||||
- `/cancel` — `POST /api/cancel` → host shellouts `pkill -INT
|
||||
claude`, emits a Note. Also surfaces as a `■ cancel turn`
|
||||
button in the state row while state=thinking.
|
||||
- `/compact` — `POST /api/compact` → host spawns
|
||||
`turn::compact_session` in the background; output streams into
|
||||
the live panel.
|
||||
- `/model <name>` — `POST /api/model` flipping `Bus::set_model`.
|
||||
Takes effect on the next turn; persisted to
|
||||
`/state/hyperhive-model` so the override survives harness
|
||||
restart / rebuild.
|
||||
- `/new-session` — `POST /api/new-session` (confirms first).
|
||||
Arms a one-shot on the Bus; next turn runs without
|
||||
`--continue`, dropping the resume session entirely.
|
||||
|
||||
Unknown `/foo` shows an error row instead of being silently sent.
|
||||
|
||||
### Per-agent endpoints
|
||||
|
||||
All POSTs return 200 (no 303 redirects). The matching mutations
|
||||
fire `LiveEvent` variants on the per-agent bus, so the client
|
||||
doesn't refetch `/api/state` on submit — the SSE stream
|
||||
delivers the new state faster anyway. Only the login flow still
|
||||
polls (session output streams in updates that aren't event-
|
||||
shaped).
|
||||
|
||||
- `POST /send` — operator-injected message into this agent's inbox.
|
||||
- `POST /login/{start,code,cancel}` — claude OAuth login flow.
|
||||
Start/cancel emit `LiveEvent::StatusChanged` to flip the
|
||||
badge to/from `needs_login_in_progress`.
|
||||
- `POST /api/cancel` — SIGINT the in-flight claude turn. Emits a
|
||||
`LiveEvent::Note`.
|
||||
- `POST /api/compact` — run `/compact` on the persistent session
|
||||
(same MCP config + system prompt + allowed tools as a normal
|
||||
turn — only the stdin payload differs). Flips state to
|
||||
`Compacting` via `Bus::set_state`, which emits
|
||||
`TurnStateChanged`.
|
||||
- `POST /api/model` (`model=<name>`) — switch the model for
|
||||
future turns. `Bus::set_model` emits `ModelChanged`.
|
||||
- `POST /api/new-session` — arm a one-shot for the next turn to
|
||||
drop `--continue`. Emits a `LiveEvent::Note`.
|
||||
- `GET /events/history` — replay buffer for the terminal.
|
||||
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
|
||||
renderer). Only accessible when `hyperhive.gui.enable = true`
|
||||
in the agent's `agent.nix`; the harness shows a 🖥 screen link
|
||||
in the state row when `gui_vnc_port` is present. Toolbar:
|
||||
`⤢ fit` CSS-downscales the canvas to the window; `⤡ match size`
|
||||
sends an RFB `SetDesktopSize` request so the server (weston)
|
||||
changes its real output resolution to the window dimensions —
|
||||
enabled once the server advertises the `ExtendedDesktopSize`
|
||||
pseudo-encoding (issue #133).
|
||||
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
|
||||
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
|
||||
Transparent to any RFB variant. VNC port comes from
|
||||
`/etc/hyperhive/gui.json` (written by the weston startup
|
||||
script in `weston-vnc.nix`).
|
||||
|
||||
Bus events (new vocabulary on `/events/stream`):
|
||||
|
||||
- `status_changed { status }` — `online` / `rate_limited` /
|
||||
`needs_login_idle` / `needs_login_in_progress`. Drives the
|
||||
alive-badge. `rate_limited` is set when the harness detects a
|
||||
429 response and cleared when the retry sleep expires.
|
||||
- `model_changed { model }` — drives the model chip.
|
||||
- `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }`
|
||||
— drives the ctx + cost badges. Emitted from
|
||||
`Bus::record_turn_usage` at turn-end; `ctx` is the last
|
||||
inference's usage (current context size), `cost` is the
|
||||
cumulative across every inference (the `result` event's
|
||||
totals).
|
||||
- `turn_state_changed { state, since_unix }` — drives the
|
||||
state badge (`idle`/`thinking`/`compacting`).
|
||||
|
||||
### Stats page
|
||||
|
||||
`GET /stats` is a separate per-agent page (served by the
|
||||
harness, linked from the per-agent page's `📊 stats →` and from
|
||||
each dashboard container row). Turn analytics, read-only, from
|
||||
`/state/hyperhive-turn-stats.sqlite`. `GET /api/stats?window=
|
||||
24h|7d|30d` returns a time-bucketed `Snapshot`; the page renders
|
||||
it with Chart.js (vendored from a CDN). Charts: turns,
|
||||
duration (p50 · p95 · avg), context tokens, token cost per
|
||||
bucket, a **turns-by-model** stacked bar (model choice drives
|
||||
token cost, so it sits directly under the cost chart), and
|
||||
doughnuts for tool / wake-source / result mix. A summary chip
|
||||
row carries window totals. `stats.rs` opens the sqlite db
|
||||
read-only and degrades to an empty snapshot on any error — the
|
||||
page is decorative, never authoritative.
|
||||
1102
flake.lock
generated
1102
flake.lock
generated
File diff suppressed because it is too large
Load diff
307
flake.nix
307
flake.nix
|
|
@ -1,178 +1,243 @@
|
|||
{
|
||||
description = "hyperhive — multi-Claude-Code-agent orchestration on nixos-containers";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
|
||||
home-manager = {
|
||||
url = "github:nix-community/home-manager/release-25.11";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
#keep-sorted start block=yes
|
||||
flake-parts = {
|
||||
url = "github:hercules-ci/flake-parts";
|
||||
#inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
hyperhive = {
|
||||
url = "git+https://git.berlin.ccc.de/vinzenz/hyperhive.git";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
nixpkgs-unstable.follows = "nixpkgs-unstable";
|
||||
};
|
||||
};
|
||||
lanzaboote = {
|
||||
url = "github:nix-community/lanzaboote/v0.4.3";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
naersk = {
|
||||
url = "github:nix-community/naersk";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
niri = {
|
||||
url = "github:sodiboo/niri-flake";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
nixpkgs-stable.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
nix-filter.url = "github:numtide/nix-filter";
|
||||
nix-vscode-extensions = {
|
||||
url = "github:nix-community/nix-vscode-extensions";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
nixos-generators = {
|
||||
url = "github:nix-community/nixos-generators";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
nixos-raspberrypi = {
|
||||
url = "github:nvmd/nixos-raspberrypi/main";
|
||||
};
|
||||
nova-shell = {
|
||||
url = "git+https://git.berlin.ccc.de/vinzenz/nova-shell";
|
||||
inputs.nixpkgs.follows = "nixpkgs-unstable";
|
||||
};
|
||||
nur = {
|
||||
url = "github:nix-community/NUR";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
flake-parts.follows = "flake-parts";
|
||||
};
|
||||
};
|
||||
servicepoint-cli = {
|
||||
url = "git+https://git.berlin.ccc.de/servicepoint/servicepoint-cli.git";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
naersk.follows = "naersk";
|
||||
nix-filter.follows = "nix-filter";
|
||||
treefmt-nix.follows = "treefmt-nix";
|
||||
};
|
||||
};
|
||||
servicepoint-simulator = {
|
||||
url = "git+https://git.berlin.ccc.de/servicepoint/servicepoint-simulator.git";
|
||||
inputs = {
|
||||
# TODO: update flake to 25.11
|
||||
# nixpkgs.follows = "nixpkgs";
|
||||
naersk.follows = "naersk";
|
||||
nix-filter.follows = "nix-filter";
|
||||
};
|
||||
};
|
||||
servicepoint-tanks = {
|
||||
url = "git+https://git.berlin.ccc.de/vinzenz/servicepoint-tanks.git?ref=service-improvements";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
stylix = {
|
||||
url = "github:nix-community/stylix/release-25.11";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
nur.follows = "nur";
|
||||
flake-parts.follows = "flake-parts";
|
||||
};
|
||||
};
|
||||
treefmt-nix = {
|
||||
url = "github:numtide/treefmt-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
zerforschen-plus = {
|
||||
url = "git+https://git.berlin.ccc.de/vinzenz/zerforschen.plus";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
#keep-sorted end
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{
|
||||
self,
|
||||
nixpkgs,
|
||||
# keep-sorted start
|
||||
niri,
|
||||
nix-vscode-extensions,
|
||||
nixpkgs-unstable,
|
||||
naersk,
|
||||
treefmt-nix,
|
||||
# keep-sorted end
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
nixosConfigurations = import ./nixosConfigurations.nix { inherit inputs lib; };
|
||||
supported-systems = lib.unique (lib.mapAttrsToList (_: v: v.pkgs.system) nixosConfigurations);
|
||||
systems = [
|
||||
"aarch64-linux"
|
||||
"x86_64-linux"
|
||||
];
|
||||
treefmt-config = {
|
||||
projectRootFile = "flake.nix";
|
||||
programs = {
|
||||
nixfmt.enable = true;
|
||||
jsonfmt.enable = true;
|
||||
prettier.enable = true;
|
||||
keep-sorted.enable = true;
|
||||
nixfmt.enable = true;
|
||||
rustfmt.enable = true;
|
||||
taplo.enable = true;
|
||||
};
|
||||
};
|
||||
forAllSystems =
|
||||
f:
|
||||
lib.genAttrs supported-systems (
|
||||
lib.genAttrs systems (
|
||||
system:
|
||||
f rec {
|
||||
inherit system;
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
treefmt-eval = treefmt-nix.lib.evalModule pkgs treefmt-config;
|
||||
naersk-lib = pkgs.callPackage naersk { };
|
||||
}
|
||||
);
|
||||
importModuleDir =
|
||||
directory:
|
||||
nixpkgs.lib.packagesFromDirectoryRecursive {
|
||||
inherit directory;
|
||||
callPackage = path: _args: path;
|
||||
};
|
||||
in
|
||||
{
|
||||
overlays = {
|
||||
unstable = final: prev: {
|
||||
unstable = import nixpkgs-unstable {
|
||||
localSystem = prev.stdenv.hostPlatform;
|
||||
inherit (prev) config;
|
||||
packages = forAllSystems (
|
||||
{ pkgs, naersk-lib, ... }:
|
||||
{
|
||||
default = naersk-lib.buildPackage {
|
||||
src = ./.;
|
||||
# librsvg ships `rsvg-convert`, which hive-c0re/build.rs
|
||||
# invokes to render branding/agent-configs.svg into the
|
||||
# PNG it embeds via `include_bytes!` (#424). Keeps the
|
||||
# raster out of git — SVG stays source-of-truth, PNG is
|
||||
# a build artifact in $OUT_DIR.
|
||||
nativeBuildInputs = [ pkgs.librsvg ];
|
||||
meta.description = "hyperhive workspace (hive-c0re, hive-ag3nt, hive-m1nd)";
|
||||
};
|
||||
# Bundled browser assets — see ./nix/frontend.nix. Output is
|
||||
# $out/{dashboard,agent}/ which the Rust binaries serve via
|
||||
# tower_http::ServeDir (wired up in Phase 4 of #273).
|
||||
frontend = pkgs.callPackage ./nix/frontend.nix {
|
||||
branding-svg = ./branding/hyperhive.svg;
|
||||
};
|
||||
# Pre-built per-container system closures. Exposed as packages
|
||||
# so operators can `nix build .#agent-base-toplevel` (or wire
|
||||
# them into their host system closure via the
|
||||
# `preBuildAgentTemplates` option on the hive-c0re module —
|
||||
# see nix/modules/hive-c0re.nix). Speeds up the first agent
|
||||
# spawn dramatically because the heavy lifting (nixpkgs +
|
||||
# claude-code + hive-ag3nt binary) is already in the store
|
||||
# when the meta evaluator goes to build the container.
|
||||
# Closes #97.
|
||||
#
|
||||
# nixosConfigurations are pinned to x86_64-linux (nixos-
|
||||
# containers only run native arch), so these toplevels are
|
||||
# only useful on an x86_64-linux host — flake check across
|
||||
# systems still tolerates evaluating them on aarch64 because
|
||||
# they're plain derivations, but `nix build` from a non-x86
|
||||
# host would only succeed via a remote x86 builder.
|
||||
agent-base-toplevel = self.nixosConfigurations.agent-base.config.system.build.toplevel;
|
||||
manager-toplevel = self.nixosConfigurations.manager.config.system.build.toplevel;
|
||||
}
|
||||
);
|
||||
|
||||
overlays = {
|
||||
default = final: prev: {
|
||||
hyperhive = self.packages.${prev.stdenv.hostPlatform.system}.default;
|
||||
# Bundled frontend dist (see ./nix/frontend.nix). Output is
|
||||
# $out/{dashboard,agent}/; consumers pick the surface they
|
||||
# need. Exposed via the overlay so containers' nix evaluations
|
||||
# can reach it as `pkgs.hyperhive-frontend` once the overlay
|
||||
# is applied (manager + agent containers both apply it via
|
||||
# `mkContainer` further down).
|
||||
hyperhive-frontend = self.packages.${prev.stdenv.hostPlatform.system}.frontend;
|
||||
};
|
||||
vscodeExtensions = nix-vscode-extensions.overlays.default;
|
||||
niri = niri.overlays.niri;
|
||||
claude-unstable =
|
||||
final: prev:
|
||||
let
|
||||
# The overlay imports its own nixpkgs-unstable instance to
|
||||
# pin claude-code there. That instance has its own config
|
||||
# (independent from the user's prev.config), so we have to
|
||||
# set allowUnfreePredicate inline to whitelist claude-code
|
||||
# specifically — otherwise the unstable import itself
|
||||
# refuses to evaluate. This is scoped: only claude-code
|
||||
# bypasses unfree, nothing else.
|
||||
unstable = import nixpkgs-unstable {
|
||||
inherit (prev.stdenv.hostPlatform) system;
|
||||
config.allowUnfreePredicate = pkg: builtins.elem (prev.lib.getName pkg) [ "claude-code" ];
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit (unstable) claude-code;
|
||||
};
|
||||
};
|
||||
|
||||
nixosModules = (importModuleDir ./nixosModules) // {
|
||||
default = {
|
||||
imports = builtins.attrValues (builtins.removeAttrs self.nixosModules [ "default" ]);
|
||||
nixosModules = {
|
||||
agent-base = ./nix/templates/agent-base.nix;
|
||||
manager = ./nix/templates/manager.nix;
|
||||
# The hive-c0re module wants `pkgs.hyperhive` for its default
|
||||
# `services.hive-c0re.package`. To avoid making operators apply an
|
||||
# overlay (which would also pollute their host pkgs with our
|
||||
# build), we thread the package straight from this flake's
|
||||
# `packages.<system>.default` via a `hyperhivePackage` argument.
|
||||
# The `claude-unstable` overlay only matters inside our container
|
||||
# builds (already applied internally in `nixosConfigurations`).
|
||||
hive-c0re = import ./nix/modules/hive-c0re.nix {
|
||||
hyperhivePackage = system: self.packages.${system}.default;
|
||||
hyperhiveFrontend = system: self.packages.${system}.frontend;
|
||||
hyperhiveFlake = "${self}";
|
||||
# Per-container toplevels — wired into `system.extraDependencies`
|
||||
# when `services.hive-c0re.preBuildAgentTemplates` is on so the
|
||||
# host system closure pre-fetches the heavy build inputs (#97).
|
||||
# Defined only for x86_64-linux because nixosConfigurations are
|
||||
# hardcoded to that system; the option's default keeps the
|
||||
# extra deps gated so aarch64 hosts don't accidentally pull
|
||||
# them in via cross-build.
|
||||
agentBaseToplevel = self.packages.x86_64-linux.agent-base-toplevel;
|
||||
managerToplevel = self.packages.x86_64-linux.manager-toplevel;
|
||||
};
|
||||
hive-forge = ./nix/modules/hive-forge.nix;
|
||||
# Convenience alias: one import covers the full hyperhive host
|
||||
# stack (hive-c0re + hive-forge, since hive-c0re already pulls
|
||||
# in hive-forge). Intended usage:
|
||||
#
|
||||
# imports = [ hyperhive.nixosModules.default ];
|
||||
# services.hive-c0re.enable = true;
|
||||
#
|
||||
default = self.nixosModules.hive-c0re;
|
||||
};
|
||||
|
||||
homeModules = importModuleDir ./homeModules;
|
||||
homeConfigurations = {
|
||||
muede = ./homeConfigurations/muede;
|
||||
ronja = ./homeConfigurations/ronja;
|
||||
};
|
||||
nixosConfigurations =
|
||||
let
|
||||
mkContainer =
|
||||
module:
|
||||
nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
module
|
||||
{
|
||||
nixpkgs.overlays = [
|
||||
self.overlays.default
|
||||
self.overlays.claude-unstable
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
agent-base = mkContainer self.nixosModules.agent-base;
|
||||
manager = mkContainer self.nixosModules.manager;
|
||||
};
|
||||
|
||||
inherit nixosConfigurations;
|
||||
devShells = forAllSystems (
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
cargo
|
||||
clippy
|
||||
librsvg # rsvg-convert — hive-c0re/build.rs invokes it (#424)
|
||||
pkg-config
|
||||
rust-analyzer
|
||||
rustc
|
||||
rustfmt
|
||||
sqlite
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
formatter = forAllSystems ({ treefmt-eval, ... }: treefmt-eval.config.build.wrapper);
|
||||
|
||||
checks = forAllSystems (
|
||||
{ treefmt-eval, ... }:
|
||||
{
|
||||
treefmt-eval,
|
||||
pkgs,
|
||||
naersk-lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
formatting = treefmt-eval.config.build.check self;
|
||||
# Clippy as a check: reuse naersk's vendored-deps environment but
|
||||
# replace the build phase with `cargo clippy --workspace --all-targets
|
||||
# -- -D warnings`. Naersk's own `mode = "clippy"` mangles the `--`
|
||||
# separator, so we go through overrideAttrs instead.
|
||||
clippy =
|
||||
(naersk-lib.buildPackage {
|
||||
src = ./.;
|
||||
# Skip the actual build; we only care about the clippy lint.
|
||||
doCheck = false;
|
||||
copyTarget = false;
|
||||
# hive-c0re/build.rs needs rsvg-convert on PATH (#424);
|
||||
# mirror the runtime derivation's nativeBuildInputs so
|
||||
# clippy's vendored-deps build phase doesn't break on
|
||||
# the missing tool.
|
||||
nativeBuildInputs = [ pkgs.librsvg ];
|
||||
}).overrideAttrs
|
||||
(old: {
|
||||
name = "${old.name}-clippy";
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.clippy ];
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
runHook postBuild
|
||||
'';
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
touch $out/.clippy-passed
|
||||
runHook postInstall
|
||||
'';
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
|||
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
packages/*/dist/
|
||||
33
frontend/README.md
Normal file
33
frontend/README.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# hyperhive frontend
|
||||
|
||||
npm workspaces project for the hyperhive browser-facing assets:
|
||||
|
||||
- `packages/shared/` — shared modules used by both surfaces (terminal
|
||||
pane, Catppuccin palette + body typography).
|
||||
- `packages/dashboard/` — the hive-c0re dashboard SPA.
|
||||
- `packages/agent/` — the per-container web UI (default agent page,
|
||||
stats, screen).
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
npm install # one-off; uses the checked-in package-lock.json
|
||||
npm run build # builds every workspace into packages/*/dist/
|
||||
```
|
||||
|
||||
The Rust binaries serve `packages/dashboard/dist/` and
|
||||
`packages/agent/dist/` via `tower_http::ServeDir` at runtime; the
|
||||
build derivation is wired up in `nix/modules/frontend.nix`. Per-agent
|
||||
additions are layered on top of the default agent dist via the
|
||||
`hyperhive.frontend.extraFiles` option in `agent.nix`.
|
||||
|
||||
## Why npm + esbuild
|
||||
|
||||
- **Hermetic**: dependencies vendored via the checked-in lockfile;
|
||||
`buildNpmPackage` in nix uses it as the source-of-truth so the
|
||||
output is reproducible without network access at build time.
|
||||
- **esbuild**: vanilla-JS bundler, no framework runtime overhead.
|
||||
Each workspace's `build.mjs` is ~30 lines.
|
||||
- **Single-PR migration**: see issue #273 for the design proposal and
|
||||
the four-commit shape (npm scaffold → nix derivations → container
|
||||
plumbing → Rust cutover).
|
||||
549
frontend/package-lock.json
generated
Normal file
549
frontend/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
{
|
||||
"name": "hyperhive-frontend",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hyperhive-frontend",
|
||||
"version": "0.0.0",
|
||||
"workspaces": [
|
||||
"packages/shared",
|
||||
"packages/dashboard",
|
||||
"packages/agent"
|
||||
],
|
||||
"devDependencies": {
|
||||
"esbuild": "0.25.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
|
||||
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
|
||||
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
|
||||
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
|
||||
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
|
||||
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
|
||||
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
|
||||
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
|
||||
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
|
||||
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
|
||||
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
|
||||
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz",
|
||||
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hive/agent": {
|
||||
"resolved": "packages/agent",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hive/dashboard": {
|
||||
"resolved": "packages/dashboard",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hive/shared": {
|
||||
"resolved": "packages/shared",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.4.4",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz",
|
||||
"integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
|
||||
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.5",
|
||||
"@esbuild/android-arm": "0.25.5",
|
||||
"@esbuild/android-arm64": "0.25.5",
|
||||
"@esbuild/android-x64": "0.25.5",
|
||||
"@esbuild/darwin-arm64": "0.25.5",
|
||||
"@esbuild/darwin-x64": "0.25.5",
|
||||
"@esbuild/freebsd-arm64": "0.25.5",
|
||||
"@esbuild/freebsd-x64": "0.25.5",
|
||||
"@esbuild/linux-arm": "0.25.5",
|
||||
"@esbuild/linux-arm64": "0.25.5",
|
||||
"@esbuild/linux-ia32": "0.25.5",
|
||||
"@esbuild/linux-loong64": "0.25.5",
|
||||
"@esbuild/linux-mips64el": "0.25.5",
|
||||
"@esbuild/linux-ppc64": "0.25.5",
|
||||
"@esbuild/linux-riscv64": "0.25.5",
|
||||
"@esbuild/linux-s390x": "0.25.5",
|
||||
"@esbuild/linux-x64": "0.25.5",
|
||||
"@esbuild/netbsd-arm64": "0.25.5",
|
||||
"@esbuild/netbsd-x64": "0.25.5",
|
||||
"@esbuild/openbsd-arm64": "0.25.5",
|
||||
"@esbuild/openbsd-x64": "0.25.5",
|
||||
"@esbuild/sunos-x64": "0.25.5",
|
||||
"@esbuild/win32-arm64": "0.25.5",
|
||||
"@esbuild/win32-ia32": "0.25.5",
|
||||
"@esbuild/win32-x64": "0.25.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
|
||||
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@hive/agent",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hive/shared": "*",
|
||||
"chart.js": "4.4.4",
|
||||
"marked": "4.3.0"
|
||||
}
|
||||
},
|
||||
"packages/dashboard": {
|
||||
"name": "@hive/dashboard",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hive/shared": "*",
|
||||
"marked": "4.3.0"
|
||||
}
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@hive/shared",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
frontend/package.json
Normal file
18
frontend/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "hyperhive-frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Frontend assets for the hyperhive dashboard and per-agent UIs. Built with esbuild into static dist directories that the Rust binaries serve via tower_http::ServeDir.",
|
||||
"workspaces": [
|
||||
"packages/shared",
|
||||
"packages/dashboard",
|
||||
"packages/agent"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"clean": "rm -rf packages/*/dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.25.5"
|
||||
}
|
||||
}
|
||||
60
frontend/packages/agent/build.mjs
Normal file
60
frontend/packages/agent/build.mjs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// esbuild build for @hive/agent. Output layout (`dist/`):
|
||||
//
|
||||
// dist/index.html served at GET /
|
||||
// dist/stats.html served at GET /stats
|
||||
// dist/screen.html served at GET /screen
|
||||
// dist/static/app.js served at /static/app.js (ESM bundle,
|
||||
// pulls in @hive/shared + marked)
|
||||
// dist/static/app.js.map source map sibling
|
||||
// dist/static/stats.js served at /static/stats.js (pulls in
|
||||
// chart.js/auto)
|
||||
// dist/static/stats.js.map source map sibling
|
||||
// dist/static/agent.css served at /static/agent.css (@import
|
||||
// resolved from @hive/shared)
|
||||
//
|
||||
// The in-container Rust binary mounts `dist/` (with per-agent
|
||||
// `hyperhive.frontend.extraFiles` layered on top) as a
|
||||
// `tower_http::ServeDir` fallback; the layout above keeps every URL
|
||||
// the HTML references reachable without rewriting paths.
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = (p) => resolve(here, 'src', p);
|
||||
const dist = (p) => resolve(here, 'dist', p);
|
||||
const staticDir = (p) => resolve(here, 'dist', 'static', p);
|
||||
|
||||
rmSync(dist(''), { recursive: true, force: true });
|
||||
mkdirSync(staticDir(''), { recursive: true });
|
||||
|
||||
// Two JS entries: the main app + the stats page. Both bundle their
|
||||
// own deps so each page can be loaded independently.
|
||||
await build({
|
||||
entryPoints: [src('app.js'), src('stats.js')],
|
||||
outdir: staticDir(''),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['es2022'],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
// Bundle the CSS — the @import lines pull in shared/base.css and
|
||||
// shared/terminal.css from the @hive/shared workspace dep.
|
||||
await build({
|
||||
entryPoints: [src('agent.css')],
|
||||
outfile: staticDir('agent.css'),
|
||||
bundle: true,
|
||||
loader: { '.css': 'css' },
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
for (const html of ['index.html', 'stats.html', 'screen.html']) {
|
||||
copyFileSync(src(html), dist(html));
|
||||
}
|
||||
|
||||
console.log('agent build ok →', dist(''));
|
||||
15
frontend/packages/agent/package.json
Normal file
15
frontend/packages/agent/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "@hive/agent",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "hive-ag3nt per-container web UI. Bundled by esbuild into a static dist; served by the in-container Rust binary at runtime via tower_http::ServeDir. Per-agent additions are layered on top via the hyperhive.frontend.extraFiles nix option.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node ./build.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hive/shared": "*",
|
||||
"marked": "4.3.0",
|
||||
"chart.js": "4.4.4"
|
||||
}
|
||||
}
|
||||
810
frontend/packages/agent/src/agent.css
Normal file
810
frontend/packages/agent/src/agent.css
Normal file
|
|
@ -0,0 +1,810 @@
|
|||
/* Shared Catppuccin palette + body typography + terminal pane styles.
|
||||
Bundled in front of the agent-only rules below via esbuild. */
|
||||
@import "@hive/shared/base.css";
|
||||
@import "@hive/shared/terminal.css";
|
||||
|
||||
/* ─── full-screen vibec0re overhaul (issue #360) ──────────────────
|
||||
Layout shape: fixed-position frosted-glass header at top, fixed-
|
||||
position composer at bottom, full-viewport terminal in between.
|
||||
The terminal scrolls — its text passes BENEATH the floating
|
||||
header/composer with backdrop-filter blur for the frosted look.
|
||||
Inbox + loose-ends move into the side-panel flyout; header pills
|
||||
surface their counts as the only chrome they get. */
|
||||
|
||||
:root {
|
||||
/* Bumped to 6em (#394) so the agent icon can be a full-height
|
||||
square identity anchor without crowding the two-row main column
|
||||
(title + nav-links on top, state strip below). */
|
||||
--agent-header-h: 6em;
|
||||
--agent-composer-h: 3.6em;
|
||||
--agent-frost-bg: rgba(30, 30, 46, 0.72);
|
||||
--agent-frost-blur: blur(12px) saturate(140%);
|
||||
}
|
||||
|
||||
html, body { height: 100%; margin: 0; }
|
||||
|
||||
/* Legacy in-page layout retained for the sibling stats page
|
||||
(`stats.html`) which doesn't apply `body.agent-shell` and stays
|
||||
on a normal-document scroll. */
|
||||
body:not(.agent-shell) {
|
||||
max-width: 110em;
|
||||
margin: 1.5em auto;
|
||||
padding: 0 1.5em;
|
||||
height: auto;
|
||||
}
|
||||
.banner {
|
||||
text-align: center;
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 0.95em;
|
||||
overflow-x: auto;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--purple-dim) 0%,
|
||||
var(--purple) 50%,
|
||||
var(--purple-dim) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-position: 50% 0;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45));
|
||||
}
|
||||
|
||||
body.agent-shell {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
/* Body itself doesn't scroll; the terminal does inside .agent-main. */
|
||||
overflow: hidden;
|
||||
/* Subtle radial accent to give the otherwise-flat full-screen
|
||||
surface some depth and reinforce the vibec0re mood. */
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 0%,
|
||||
rgba(203, 166, 247, 0.06) 0%,
|
||||
transparent 60%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.agent-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
min-height: var(--agent-header-h);
|
||||
display: flex;
|
||||
/* align-items: stretch lets the icon take the full header height
|
||||
(it sizes itself via aspect-ratio off the stretched height). The
|
||||
main column + pills column self-centre via inner layout. */
|
||||
align-items: stretch;
|
||||
gap: 0.9em;
|
||||
padding: 0.5em 1em;
|
||||
background: var(--agent-frost-bg);
|
||||
-webkit-backdrop-filter: var(--agent-frost-blur);
|
||||
backdrop-filter: var(--agent-frost-blur);
|
||||
border-bottom: 1px solid var(--purple-dim);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Main column: title row on top, state strip below (#394). Centred
|
||||
vertically against the full-height icon on the left. */
|
||||
.agent-header-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.45em;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.agent-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8em;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.agent-header-title-row h2 {
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.agent-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em 0.8em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-state-row {
|
||||
margin: 0;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
/* Right cluster — flyout pills stacked / inline with the overflow
|
||||
trigger. Vertically centred against the full-height icon, no
|
||||
wrap; pills can drop to a row of their own under crowding via
|
||||
the flex-wrap of `.agent-header-pills` itself. */
|
||||
.agent-header-pills {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
h2, h3 {
|
||||
color: var(--purple);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
||||
}
|
||||
.agent-icon {
|
||||
/* Square identity anchor (#394 — mara's spec). Explicit em sizing
|
||||
so the <img>'s intrinsic (large) dimensions don't push the
|
||||
parent flex container open via `align-items: stretch`-driven
|
||||
height feedback. Width = header content area (header min-h 6em
|
||||
- 2 × 0.5em padding ≈ 5em). Sticks to the top so a state-row
|
||||
wrap doesn't drag the icon down with it. (#411) */
|
||||
width: 5em;
|
||||
height: 5em;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 18px -2px rgba(203, 166, 247, 0.4);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Meta-nav links (stats / screen / forge / dashboard / extras) —
|
||||
no underline (#394 mara's spec); hover lights with cyan glow +
|
||||
subtle background tint. Reads as a row of soft tabs rather than
|
||||
default-styled inline anchors. */
|
||||
.agent-nav-link {
|
||||
color: var(--cyan);
|
||||
text-decoration: none;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 3px;
|
||||
text-shadow: 0 0 4px rgba(137, 220, 235, 0.4);
|
||||
transition: color 0.15s ease, text-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.agent-nav-link:hover {
|
||||
color: var(--fg);
|
||||
background: rgba(137, 220, 235, 0.08);
|
||||
text-shadow: 0 0 10px rgba(137, 220, 235, 0.85);
|
||||
}
|
||||
|
||||
/* Overflow menu trigger — `⋯` round button on the right of the
|
||||
pills row. Quiet by default, lights on hover / open (#394). */
|
||||
.overflow-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--purple-dim);
|
||||
color: var(--muted);
|
||||
border-radius: 999px;
|
||||
width: 2em;
|
||||
height: 1.8em;
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.overflow-btn:hover,
|
||||
.overflow-btn[aria-expanded="true"] {
|
||||
color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
box-shadow: 0 0 10px -2px var(--purple);
|
||||
}
|
||||
|
||||
/* Overflow popover — rebuild + new-session (and the dashboard
|
||||
back-link, prepended in app.js setHeader). Positioned in JS so
|
||||
the menu's top-right corner anchors under the trigger button.
|
||||
`:not([hidden])` scoping (#411): the `[hidden]` HTML attribute
|
||||
sets `display: none` via the UA stylesheet, but author CSS's
|
||||
`display: flex` would override that. Scope display rules so
|
||||
they apply only when the menu is unhidden. */
|
||||
.overflow-menu:not([hidden]) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15em;
|
||||
}
|
||||
.overflow-menu {
|
||||
position: fixed;
|
||||
background: var(--agent-frost-bg);
|
||||
-webkit-backdrop-filter: var(--agent-frost-blur);
|
||||
backdrop-filter: var(--agent-frost-blur);
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 6px;
|
||||
padding: 0.35em;
|
||||
z-index: 40;
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45);
|
||||
min-width: 14em;
|
||||
}
|
||||
.overflow-item {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 0.9em;
|
||||
text-align: left;
|
||||
padding: 0.4em 0.7em;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
letter-spacing: 0.06em;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.overflow-item:hover {
|
||||
background: rgba(203, 166, 247, 0.08);
|
||||
border-color: var(--purple-dim);
|
||||
}
|
||||
.overflow-item-icon {
|
||||
font-size: 1.05em;
|
||||
width: 1.4em;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.overflow-item-rebuild { color: var(--amber); }
|
||||
.overflow-item-new-session { color: var(--amber); }
|
||||
.overflow-item-rebuild:hover,
|
||||
.overflow-item-new-session:hover {
|
||||
background: rgba(250, 179, 135, 0.1);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.overflow-item-dashboard { color: var(--cyan); }
|
||||
.overflow-item-dashboard:hover {
|
||||
background: rgba(137, 220, 235, 0.1);
|
||||
border-color: var(--cyan);
|
||||
}
|
||||
.overflow-item:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* Header pill — inbox / loose-ends triggers. Compact, count-prominent. */
|
||||
.header-pill {
|
||||
background: transparent;
|
||||
border: 1px solid var(--purple-dim);
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 999px;
|
||||
padding: 0.25em 0.7em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.header-pill:hover {
|
||||
border-color: var(--purple);
|
||||
color: var(--purple);
|
||||
box-shadow: 0 0 10px -2px var(--purple);
|
||||
}
|
||||
.header-pill-icon { font-size: 1.05em; line-height: 1; }
|
||||
.header-pill-label { color: var(--muted); }
|
||||
.header-pill-count {
|
||||
background: var(--purple-dim);
|
||||
color: var(--purple);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.5em;
|
||||
min-width: 1.6em;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.header-pill-inbox .header-pill-count {
|
||||
background: rgba(250, 179, 135, 0.18);
|
||||
color: var(--amber);
|
||||
}
|
||||
.header-pill-loose .header-pill-count {
|
||||
background: rgba(243, 139, 168, 0.18);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.agent-main {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Login flow overlay: only rendered when status != online. Sits
|
||||
centred over the (likely-empty) terminal area; doesn't take chrome
|
||||
space in the normal online flow. */
|
||||
.agent-status-overlay {
|
||||
position: absolute;
|
||||
top: calc(var(--agent-header-h) + 1.5em);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 44em;
|
||||
width: calc(100% - 3em);
|
||||
z-index: 10;
|
||||
}
|
||||
.agent-status-overlay:empty { display: none; }
|
||||
.agent-status-overlay > * {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 6px;
|
||||
padding: 1em 1.2em;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.agent-composer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
min-height: var(--agent-composer-h);
|
||||
background: var(--agent-frost-bg);
|
||||
-webkit-backdrop-filter: var(--agent-frost-blur);
|
||||
backdrop-filter: var(--agent-frost-blur);
|
||||
border-top: 1px solid var(--purple-dim);
|
||||
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.agent-composer .term-input {
|
||||
/* The composer is its own chrome now — drop the in-terminal-wrap
|
||||
padding the legacy layout assumed. */
|
||||
padding: 0.45em 1em;
|
||||
}
|
||||
.agent-composer .term-input .sendform-term {
|
||||
/* No dashed top-border in the floating composer — the box-shadow
|
||||
and frosted border already separate it from the terminal. */
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.meta { color: var(--muted); font-size: 0.85em; }
|
||||
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
|
||||
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); }
|
||||
code { background: rgba(203, 166, 247, 0.12); padding: 0.05em 0.3em; border-radius: 2px; }
|
||||
a {
|
||||
color: var(--cyan);
|
||||
text-shadow: 0 0 4px rgba(137, 220, 235, 0.5);
|
||||
}
|
||||
a:hover { color: var(--fg); text-shadow: 0 0 12px rgba(137, 220, 235, 0.9); }
|
||||
.btn {
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--purple);
|
||||
color: var(--purple);
|
||||
padding: 0.25em 0.8em;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.btn {
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
transition: box-shadow 0.15s ease, text-shadow 0.15s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background: rgba(205, 214, 244, 0.06);
|
||||
text-shadow: 0 0 10px currentColor;
|
||||
box-shadow: 0 0 10px -2px currentColor;
|
||||
}
|
||||
.btn-login { color: var(--amber); border-color: var(--amber); }
|
||||
.btn-cancel { color: var(--red); border-color: var(--red); font-size: 0.85em; padding: 0.15em 0.6em; }
|
||||
/* `.btn-rebuild` was the per-agent header chip — moved into the
|
||||
overflow menu in #394 (`.overflow-item-rebuild` covers it now).
|
||||
The dashboard has its own `.btn-rebuild` rule for the per-row
|
||||
R3BU1LD form on the SW4RM tab; this one was specific to the
|
||||
per-agent header.
|
||||
`.btn-send` was a green send-button variant — orphaned since
|
||||
the dashboard's compose form was retired; no live consumer left
|
||||
in either the agent or dashboard tree. */
|
||||
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
||||
.sendform input {
|
||||
font-family: inherit; font-size: 1em;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.4em 0.6em;
|
||||
flex: 1;
|
||||
}
|
||||
.sendform input:focus { outline: 1px solid var(--purple); }
|
||||
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
||||
.loginform input {
|
||||
font-family: inherit; font-size: 1em;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.4em 0.6em;
|
||||
flex: 1;
|
||||
}
|
||||
.loginform input:focus { outline: 1px solid var(--purple); }
|
||||
pre.diff {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.6em 0.8em;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 30em;
|
||||
}
|
||||
/* Per-agent inbox section — collapsible, dim, lives between the
|
||||
state row and the terminal so the operator can peek at what
|
||||
landed without scrolling through the live tail. */
|
||||
.agent-inbox {
|
||||
margin: 0.4em 0;
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.agent-inbox > summary {
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.05em;
|
||||
list-style: none;
|
||||
}
|
||||
.agent-inbox > summary::marker { content: ''; }
|
||||
.agent-inbox[open] > summary > span::before { content: ''; }
|
||||
.agent-inbox ul {
|
||||
list-style: none;
|
||||
padding: 0.4em 0.8em;
|
||||
margin: 0.3em 0 0;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-left: 2px solid var(--purple-dim);
|
||||
max-height: 16em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Inbox / loose-ends rows: header (from / sep / ts) on one line,
|
||||
body on its own line below — gives the body the full panel width
|
||||
instead of squeezing it into a fourth grid column that wrapped
|
||||
long messages over many narrow lines (issue #376). */
|
||||
.agent-inbox li {
|
||||
padding: 0.4em 0;
|
||||
display: block;
|
||||
}
|
||||
.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; margin-left: 0.5em; }
|
||||
.agent-inbox .inbox-from { color: var(--amber); }
|
||||
.agent-inbox .inbox-sep { color: var(--muted); margin-left: 0.4em; }
|
||||
.agent-inbox .inbox-body {
|
||||
display: block;
|
||||
color: var(--fg);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-top: 0.3em;
|
||||
padding-left: 0.8em;
|
||||
border-left: 2px solid var(--purple-dim);
|
||||
}
|
||||
.agent-inbox li.inbox-reply {
|
||||
padding-left: 1em;
|
||||
border-left: 2px solid var(--border);
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
.agent-inbox .inbox-reply-tag { color: var(--muted); font-size: 0.85em; }
|
||||
|
||||
.agent-inbox .answer-form {
|
||||
/* Block-level under the new layout — `grid-column: 1 / -1` was
|
||||
for the legacy grid; under block layout the form naturally
|
||||
starts on its own row. */
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
align-items: flex-start;
|
||||
margin-top: 0.5em;
|
||||
padding-left: 0.8em;
|
||||
}
|
||||
.agent-inbox .answer-form textarea {
|
||||
flex: 1;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 0.3em;
|
||||
resize: vertical;
|
||||
}
|
||||
.agent-inbox .answer-form button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 0.3em 0.7em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent-inbox .answer-form button:hover:not(:disabled) {
|
||||
border-color: var(--purple);
|
||||
color: var(--purple);
|
||||
}
|
||||
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
|
||||
.agent-inbox .answer-status { color: var(--muted); align-self: center; }
|
||||
|
||||
.last-turn {
|
||||
color: var(--muted);
|
||||
font-size: 0.8em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.model-chip {
|
||||
display: inline-block;
|
||||
padding: 0.1em 0.6em;
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 999px;
|
||||
color: var(--cyan);
|
||||
font-size: 0.78em;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
/* Context-window badge. Mirrors Claude Code's bottom-right "N tokens"
|
||||
chip — single primary number (total prompt tokens in use), full
|
||||
breakdown on hover. Sized/coloured like a peer of model-chip so
|
||||
the state row reads as one row of chrome. */
|
||||
.ctx-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1em 0.6em;
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 999px;
|
||||
color: var(--green);
|
||||
font-size: 0.78em;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: default;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* Harness reachability badge. Same chip shape + sizing as
|
||||
`.state-badge` / `.model-chip` so the state row stays visually
|
||||
uniform; colour communicates the actual reachability state. */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25em 0.8em;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.status-badge.status-loading { color: var(--muted); border-color: var(--purple-dim); }
|
||||
.status-badge.status-online { color: var(--green); border-color: var(--green);
|
||||
text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
|
||||
.status-badge.status-rate-limited { color: var(--red); border-color: var(--red);
|
||||
text-shadow: 0 0 6px rgba(243, 139, 168, 0.55); }
|
||||
.status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); }
|
||||
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); }
|
||||
/* Orphaned in #394 — `.btn-dashlink` chip beside the title moved
|
||||
into the overflow menu (`.overflow-item-dashboard` covers it). */
|
||||
.btn-cancel-turn {
|
||||
font-family: inherit;
|
||||
font-size: 0.8em;
|
||||
letter-spacing: 0.08em;
|
||||
background: transparent;
|
||||
color: var(--red);
|
||||
border: 1px solid var(--red);
|
||||
border-radius: 999px;
|
||||
padding: 0.2em 0.8em;
|
||||
cursor: pointer;
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
transition: box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.btn-cancel-turn:hover {
|
||||
background: rgba(243, 139, 168, 0.1);
|
||||
box-shadow: 0 0 10px -2px currentColor;
|
||||
}
|
||||
/* Orphaned in #394 — `.btn-new-session` round-pill moved into the
|
||||
overflow menu (`.overflow-item-new-session` covers it; the
|
||||
`:disabled` opacity treatment lives on the shared
|
||||
`.overflow-item:disabled` rule). */
|
||||
.state-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25em 0.8em;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.05em;
|
||||
transition: color 280ms ease, border-color 280ms ease,
|
||||
box-shadow 280ms ease, background 280ms ease;
|
||||
}
|
||||
.state-badge.state-loading {
|
||||
color: var(--muted); border-color: var(--purple-dim);
|
||||
}
|
||||
.state-badge.state-offline {
|
||||
color: var(--muted); border-color: var(--muted);
|
||||
}
|
||||
.state-badge.state-idle {
|
||||
color: var(--cyan); border-color: var(--cyan);
|
||||
text-shadow: 0 0 6px rgba(137, 220, 235, 0.55);
|
||||
}
|
||||
.state-badge.state-thinking {
|
||||
color: var(--amber); border-color: var(--amber);
|
||||
text-shadow: 0 0 6px rgba(250, 179, 135, 0.65);
|
||||
animation: badge-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.state-badge.state-compacting {
|
||||
color: var(--purple); border-color: var(--purple);
|
||||
text-shadow: 0 0 6px rgba(203, 166, 247, 0.65);
|
||||
animation: badge-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.state-badge.state-just-changed {
|
||||
animation: state-flash 600ms ease-out;
|
||||
}
|
||||
@keyframes state-flash {
|
||||
0% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
|
||||
60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; }
|
||||
100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
|
||||
}
|
||||
/* Full-screen overrides for the shared terminal rules. The base
|
||||
`.terminal-wrap` (in shared/src/terminal.css) ships a crust-on-
|
||||
black frame for the in-page case; the agent page now owns the
|
||||
whole viewport so the frame chrome would be redundant noise.
|
||||
`.live.terminal` similarly drops the in-page max-height cap so
|
||||
it can fill the main area top-to-bottom; the floating header +
|
||||
composer overlay it via fixed positioning. */
|
||||
.agent-main .terminal-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.agent-main .live.terminal {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
/* Scroll behind the floating header/composer, but keep the first
|
||||
and last rows reachable with extra padding inside the scroll
|
||||
area. scroll-padding-* keeps anchor-jumps (the `↓ N new` pill,
|
||||
focus restore) clear of the floats too. */
|
||||
padding-top: calc(var(--agent-header-h) + 0.8em);
|
||||
padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
||||
scroll-padding-top: calc(var(--agent-header-h) + 0.8em);
|
||||
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
||||
overflow: auto;
|
||||
}
|
||||
/* Tail pill (↓ N new): nudged up so it floats clear of the composer
|
||||
rather than colliding with the frosted bar. z-index bumped above
|
||||
the composer (z-30) so the pill sits on the top layer instead of
|
||||
being clipped by the floating chrome (issue #375). */
|
||||
.agent-main .tail-pill {
|
||||
bottom: calc(var(--agent-composer-h) + 0.6em);
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
/* Composer chrome — used to live inside `.terminal-wrap`; now lives
|
||||
inside the fixed `.agent-composer` defined further up. The base
|
||||
rules below stay scoped to whichever ancestor owns it. */
|
||||
.term-input .sendform-term {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5em;
|
||||
/* The dashed in-frame separator is dropped — see the
|
||||
.agent-composer .term-input override above for the floating-bar
|
||||
variant. */
|
||||
border-top: 1px dashed var(--purple-dim);
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
.term-input .prompt, .term-input .submit-hint {
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
.term-input .prompt {
|
||||
color: var(--green);
|
||||
text-shadow: 0 0 6px rgba(166, 227, 161, 0.6);
|
||||
user-select: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.term-input textarea {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
padding: 0.2em 0;
|
||||
caret-color: var(--green);
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
line-height: 1.4;
|
||||
min-height: 1.4em;
|
||||
}
|
||||
.term-input textarea::placeholder { color: var(--muted); }
|
||||
.term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; }
|
||||
.term-input.disabled .prompt { color: var(--muted); text-shadow: none; }
|
||||
.term-input.disabled textarea { color: var(--muted); }
|
||||
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
|
||||
|
||||
/* ─── side panel (singleton drawer) ────────────────────────────────
|
||||
Inbox + loose-ends details open here instead of expanding inline
|
||||
(issue #360). Copy of the dashboard's side-panel pattern —
|
||||
candidate for extraction into @hive/shared once both surfaces
|
||||
stabilize. */
|
||||
.side-panel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
/* Closed: ignore pointer events so the agent page underneath stays
|
||||
interactive; `.open` flips it back on. */
|
||||
pointer-events: none;
|
||||
}
|
||||
.side-panel-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.side-panel-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(640px, 92vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-elev);
|
||||
border-left: 2px solid var(--purple);
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.45);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
.side-panel.open { pointer-events: auto; }
|
||||
.side-panel.open .side-panel-backdrop { opacity: 1; }
|
||||
.side-panel.open .side-panel-drawer { transform: translateX(0); }
|
||||
.side-panel-head {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
padding: 0.7em 1em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.side-panel-title {
|
||||
color: var(--purple);
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.05em;
|
||||
word-break: break-all;
|
||||
}
|
||||
.side-panel-close {
|
||||
flex: 0 0 auto;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
padding: 0.25em 0.55em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.side-panel-close:hover { border-color: var(--red); color: var(--red); }
|
||||
.side-panel-body {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: 0.8em 1em;
|
||||
}
|
||||
/* Inbox / loose-ends lists rendered into the side-panel body. The
|
||||
legacy <details>-collapsible variant of .agent-inbox is gone, so
|
||||
here we strip the inbox-only chrome (background, border-left) and
|
||||
let the panel body's own padding own the framing. */
|
||||
.side-panel-body .agent-inbox {
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
color: var(--fg);
|
||||
}
|
||||
.side-panel-body .agent-inbox ul {
|
||||
background: transparent;
|
||||
border-left: 0;
|
||||
padding: 0;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Empty-state placeholders for the side panel (when count drops to 0
|
||||
between the click and the render — rare, but possible). */
|
||||
.side-panel-empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
padding: 1em 0;
|
||||
}
|
||||
1356
frontend/packages/agent/src/app.js
Normal file
1356
frontend/packages/agent/src/app.js
Normal file
File diff suppressed because it is too large
Load diff
109
frontend/packages/agent/src/index.html
Normal file
109
frontend/packages/agent/src/index.html
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>hyperhive agent</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon">
|
||||
<link rel="stylesheet" href="/static/agent.css">
|
||||
</head>
|
||||
<body class="agent-shell">
|
||||
|
||||
<!-- Fixed-overlay header (#394 redesign): two-row layout in the
|
||||
main column — row 1 carries the title + meta-nav, row 2 carries
|
||||
the live state strip. The agent icon eats the full header
|
||||
height on the left as the identity anchor; flyout pills + an
|
||||
overflow menu trigger sit on the right. Frosted glass over the
|
||||
terminal — backdrop-filter blur shows the scrolled terminal
|
||||
text behind. -->
|
||||
<header class="agent-header" id="agent-header">
|
||||
<img class="agent-icon" src="/icon" alt="">
|
||||
|
||||
<div class="agent-header-main">
|
||||
<div class="agent-header-row agent-header-title-row">
|
||||
<h2 id="title">◆ … ◆</h2>
|
||||
<!-- Meta-nav: backend-supplied links (stats / screen / forge /
|
||||
…) plus a client-injected `↑ dashboard` link prepended in
|
||||
setHeader so the host dashboard stays one click away
|
||||
without a separate button styled differently. -->
|
||||
<nav class="meta agent-nav" id="meta-links"></nav>
|
||||
</div>
|
||||
|
||||
<div id="state-row" class="agent-state-row agent-header-row">
|
||||
<span id="alive-badge" class="status-badge status-loading" title="harness reachability">…</span>
|
||||
<span id="state-badge" class="state-badge state-loading">… booting</span>
|
||||
<span id="model-chip" class="model-chip" hidden></span>
|
||||
<span id="ctx-badge" class="ctx-badge" hidden title="tokens used in the current context window"></span>
|
||||
<span id="cost-badge" class="ctx-badge" hidden title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"></span>
|
||||
<span id="last-turn" class="last-turn" hidden></span>
|
||||
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right cluster: flyout triggers + overflow menu. Pills stay
|
||||
hidden until their list is non-empty; the overflow `⋯` is
|
||||
always visible (rebuild + new-session live inside it per
|
||||
#394 — both rare, both destructive, both deserve one extra
|
||||
click). -->
|
||||
<div class="agent-header-pills">
|
||||
<button type="button" id="inbox-pill" class="header-pill header-pill-inbox" hidden
|
||||
title="open inbox flyout">
|
||||
<span class="header-pill-icon" aria-hidden="true">📬</span>
|
||||
<span class="header-pill-label">inbox</span>
|
||||
<span class="header-pill-count" id="inbox-count">0</span>
|
||||
</button>
|
||||
<button type="button" id="loose-ends-pill" class="header-pill header-pill-loose" hidden
|
||||
title="open loose-ends flyout">
|
||||
<span class="header-pill-icon" aria-hidden="true">🪢</span>
|
||||
<span class="header-pill-label">loose ends</span>
|
||||
<span class="header-pill-count" id="loose-ends-count">0</span>
|
||||
</button>
|
||||
<button type="button" id="overflow-btn" class="overflow-btn"
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
title="more actions">⋯</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Overflow popover. Sits outside the header so the header's
|
||||
`overflow: hidden`-adjacent ancestors don't clip it; positioned
|
||||
in JS relative to the overflow button (top-right anchor). -->
|
||||
<div id="overflow-menu" class="overflow-menu" role="menu" hidden></div>
|
||||
|
||||
<!-- Main content area. The terminal fills it edge-to-edge and
|
||||
scrolls behind the floating header + composer. The `#status`
|
||||
overlay renders only when login is required (transient first-
|
||||
time-setup state); otherwise the terminal owns the screen. -->
|
||||
<main class="agent-main" id="agent-main">
|
||||
<div id="status" class="agent-status-overlay"></div>
|
||||
<div class="terminal-wrap">
|
||||
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Fixed-overlay composer. Same frosted-glass treatment as the
|
||||
header for symmetric framing. Empty until the harness sets up
|
||||
the textarea via `renderTermInput`. -->
|
||||
<footer class="agent-composer" id="agent-composer">
|
||||
<div id="term-input" class="term-input"></div>
|
||||
</footer>
|
||||
|
||||
<!-- Slide-in side panel. Singleton — JS swaps the title + body
|
||||
and toggles `.open`. Shared shape with the dashboard's panel
|
||||
(candidate for extraction into @hive/shared in a follow-up). -->
|
||||
<div id="side-panel" class="side-panel" aria-hidden="true">
|
||||
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
|
||||
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
|
||||
aria-labelledby="side-panel-title">
|
||||
<header class="side-panel-head">
|
||||
<span class="side-panel-title" id="side-panel-title"></span>
|
||||
<button type="button" class="side-panel-close" id="side-panel-close"
|
||||
title="close (esc)">✕</button>
|
||||
</header>
|
||||
<div class="side-panel-body" id="side-panel-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
|
||||
the marked npm package into app.js. -->
|
||||
<script type="module" src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
770
frontend/packages/agent/src/screen.html
Normal file
770
frontend/packages/agent/src/screen.html
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>screen</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon">
|
||||
<style>
|
||||
/* Catppuccin Mocha palette (mirrors base.css) */
|
||||
:root {
|
||||
--base: #1e1e2e;
|
||||
--mantle: #181825;
|
||||
--crust: #11111b;
|
||||
--text: #cdd6f4;
|
||||
--subtext0:#a6adc8;
|
||||
--surface0:#313244;
|
||||
--surface1:#45475a;
|
||||
--blue: #89b4fa;
|
||||
--red: #f38ba8;
|
||||
--green: #a6e3a1;
|
||||
--yellow: #f9e2af;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; background: var(--base); color: var(--text);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
||||
font-size: 14px; }
|
||||
#toolbar {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.4rem 0.75rem; background: var(--mantle);
|
||||
border-bottom: 1px solid var(--surface0);
|
||||
}
|
||||
#toolbar a { color: var(--blue); text-decoration: none; font-size: 0.85rem; }
|
||||
#toolbar a:hover { text-decoration: underline; }
|
||||
.tbtn {
|
||||
padding: 0.15rem 0.5rem; font-size: 0.72rem; font-family: inherit;
|
||||
background: var(--surface0); color: var(--subtext0);
|
||||
border: 1px solid var(--surface1); border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.tbtn.active { color: var(--green); border-color: var(--green); }
|
||||
.tbtn:disabled { opacity: 0.4; cursor: default; }
|
||||
#status { margin-left: auto; font-size: 0.75rem; color: var(--subtext0); }
|
||||
#status.connected { color: var(--green); }
|
||||
#status.error { color: var(--red); }
|
||||
#debug-log {
|
||||
position: fixed; bottom: 0; left: 0; right: 0; max-height: 40vh;
|
||||
overflow-y: auto; background: rgba(17,17,27,0.95);
|
||||
border-top: 1px solid var(--surface1);
|
||||
font-size: 0.72rem; font-family: ui-monospace, monospace;
|
||||
padding: 0.4rem 0.6rem; z-index: 100;
|
||||
display: none; /* hidden by default; toggled by toolbar button */
|
||||
}
|
||||
#debug-log .dbg-line { color: var(--subtext0); margin: 1px 0; white-space: pre; }
|
||||
#debug-log .dbg-line.err { color: var(--red); }
|
||||
#debug-log .dbg-line.ok { color: var(--green); }
|
||||
#debug-log .dbg-line.send { color: var(--blue); }
|
||||
#canvas-wrap {
|
||||
display: flex; justify-content: center; align-items: flex-start;
|
||||
width: 100%; height: calc(100% - 36px); overflow: auto;
|
||||
background: var(--crust);
|
||||
}
|
||||
/* Fit mode: centre the canvas (relayoutCanvas() scales it in JS to
|
||||
fit the wrap) and clip any sub-pixel rounding overflow. */
|
||||
#canvas-wrap.fit { align-items: center; overflow: hidden; }
|
||||
canvas { display: block; cursor: default; }
|
||||
/* In fit mode relayoutCanvas() sets the canvas display size explicitly.
|
||||
The canvas is a flex item, and flex items default to
|
||||
min-width/min-height: auto — which resolves to the canvas's intrinsic
|
||||
framebuffer resolution and clamps the JS-set size straight back up,
|
||||
defeating the downscale (the bug behind #133 round 1). Pin the canvas
|
||||
to exactly the size relayoutCanvas() sets: min-* 0 lifts the clamp,
|
||||
flex: none stops flex grow/shrink from fighting it. */
|
||||
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
|
||||
#msg {
|
||||
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
||||
background: var(--surface0); color: var(--yellow); border-radius: 6px;
|
||||
padding: 0.4rem 0.9rem; font-size: 0.8rem;
|
||||
opacity: 0; transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<strong>🖥 screen</strong>
|
||||
<a href="/" title="back to agent page">← agent</a>
|
||||
<button id="fit-toggle" class="tbtn" title="Toggle fit-to-window scaling">⤢ fit</button>
|
||||
<button id="match-toggle" class="tbtn" title="Resize the remote desktop to fit this window" disabled>⤡ match size</button>
|
||||
<button id="debug-toggle" class="tbtn" title="Toggle RFB debug log">debug</button>
|
||||
<span id="status">connecting…</span>
|
||||
</div>
|
||||
<div id="canvas-wrap"><canvas id="c"></canvas></div>
|
||||
<div id="msg"></div>
|
||||
<div id="debug-log"></div>
|
||||
|
||||
<script>
|
||||
// Minimal RFB-over-WebSocket renderer.
|
||||
// Connects to /screen/ws on the same host; the harness relays raw
|
||||
// RFB bytes to the VNC server running inside the container.
|
||||
//
|
||||
// This is a deliberately thin implementation — enough to display the
|
||||
// desktop and forward pointer + keyboard events. For a production-grade
|
||||
// viewer, replace with noVNC (issue #52 vendors the full bundle).
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const canvas = document.getElementById('c');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const status = document.getElementById('status');
|
||||
const msg = document.getElementById('msg');
|
||||
const debugLog = document.getElementById('debug-log');
|
||||
const debugBtn = document.getElementById('debug-toggle');
|
||||
const fitBtn = document.getElementById('fit-toggle');
|
||||
const matchBtn = document.getElementById('match-toggle');
|
||||
const canvasWrap = document.getElementById('canvas-wrap');
|
||||
|
||||
// --- Debug log ---
|
||||
let debugVisible = false;
|
||||
debugBtn.addEventListener('click', () => {
|
||||
debugVisible = !debugVisible;
|
||||
debugLog.style.display = debugVisible ? 'block' : 'none';
|
||||
debugBtn.classList.toggle('active', debugVisible);
|
||||
});
|
||||
|
||||
// --- Fit-to-window toggle ---
|
||||
// Scales the canvas down so the whole desktop is visible without
|
||||
// scrolling. The canvas's intrinsic resolution (width/height attrs)
|
||||
// is untouched — only its CSS display size changes, set explicitly
|
||||
// by relayoutCanvas(). Pointer coordinates are rescaled in
|
||||
// sendPointer to stay accurate. Persisted in localStorage; default
|
||||
// is fit-on.
|
||||
let fitMode = localStorage.getItem('screen-fit') !== 'off';
|
||||
// Size the canvas. In fit mode, scale down (never up) to the wrap,
|
||||
// preserving aspect ratio. Explicit px sizing rather than CSS
|
||||
// max-width/max-height: on a flex item those are overridden by the
|
||||
// automatic minimum size, so fit mode was a silent no-op — the
|
||||
// oversized canvas just got centred and clipped (issue #133).
|
||||
function relayoutCanvas() {
|
||||
if (fitMode && canvas.width && canvas.height
|
||||
&& canvasWrap.clientWidth && canvasWrap.clientHeight) {
|
||||
const scale = Math.min(
|
||||
canvasWrap.clientWidth / canvas.width,
|
||||
canvasWrap.clientHeight / canvas.height,
|
||||
1,
|
||||
);
|
||||
canvas.style.width = (canvas.width * scale) + 'px';
|
||||
canvas.style.height = (canvas.height * scale) + 'px';
|
||||
} else if (!fitMode) {
|
||||
canvas.style.width = '';
|
||||
canvas.style.height = '';
|
||||
}
|
||||
}
|
||||
function applyFitMode() {
|
||||
canvasWrap.classList.toggle('fit', fitMode);
|
||||
fitBtn.classList.toggle('active', fitMode);
|
||||
relayoutCanvas();
|
||||
}
|
||||
fitBtn.addEventListener('click', () => {
|
||||
fitMode = !fitMode;
|
||||
localStorage.setItem('screen-fit', fitMode ? 'on' : 'off');
|
||||
applyFitMode();
|
||||
});
|
||||
window.addEventListener('resize', relayoutCanvas);
|
||||
applyFitMode();
|
||||
|
||||
// --- Match-size: resize the remote desktop to this window ---
|
||||
// Sends an RFB SetDesktopSize request so the VNC server (weston)
|
||||
// changes its actual output resolution to match the browser
|
||||
// viewport — sharper than fit-mode's CSS downscale. The button is
|
||||
// enabled only once the server has advertised the ExtendedDesktopSize
|
||||
// pseudo-encoding (a -308 rect). (issue #133)
|
||||
let extDesktopSupported = false;
|
||||
let screenId = 1; // captured from the server's ExtendedDesktopSize advert
|
||||
matchBtn.addEventListener('click', () => {
|
||||
if (!extDesktopSupported) return;
|
||||
// Even dimensions — some servers reject odd ones.
|
||||
const w = Math.max(2, canvasWrap.clientWidth & ~1);
|
||||
const h = Math.max(2, canvasWrap.clientHeight & ~1);
|
||||
dbg('→ request desktop resize to ' + w + 'x' + h, 'send');
|
||||
sendSetDesktopSize(w, h);
|
||||
});
|
||||
|
||||
function hex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join(' ');
|
||||
}
|
||||
|
||||
function dbg(text, cls) {
|
||||
console.log('[rfb]', text);
|
||||
const line = document.createElement('div');
|
||||
line.className = 'dbg-line' + (cls ? ' ' + cls : '');
|
||||
line.textContent = text;
|
||||
debugLog.appendChild(line);
|
||||
debugLog.scrollTop = debugLog.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text, cls) {
|
||||
status.textContent = text;
|
||||
status.className = cls || '';
|
||||
if (cls === 'error') dbg('ERROR: ' + text, 'err');
|
||||
}
|
||||
|
||||
function flash(text) {
|
||||
msg.textContent = text;
|
||||
msg.style.opacity = '1';
|
||||
setTimeout(() => { msg.style.opacity = '0'; }, 2500);
|
||||
}
|
||||
|
||||
// --- WebSocket connection ---
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/screen/ws`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => { dbg('WebSocket open — starting RFB handshake', 'ok'); setStatus('handshaking…'); };
|
||||
ws.onerror = () => setStatus('connection error', 'error');
|
||||
ws.onclose = (e) => {
|
||||
setStatus(`disconnected (${e.code})`, 'error');
|
||||
flash('VNC disconnected — reload to reconnect');
|
||||
};
|
||||
|
||||
// Accumulate received bytes in a simple ring queue
|
||||
const chunks = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
chunks.push(new Uint8Array(ev.data));
|
||||
totalBytes += ev.data.byteLength;
|
||||
processRfb();
|
||||
};
|
||||
|
||||
// --- Minimal RFB state machine ---
|
||||
// We implement just enough to handshake and receive FramebufferUpdate
|
||||
// rectangles encoded as Raw (encoding 0). Other encodings are skipped.
|
||||
// Keyboard and pointer events are forwarded.
|
||||
|
||||
let state = 'version';
|
||||
let fbW = 0, fbH = 0;
|
||||
let pixelFormat = null; // set after ServerInit
|
||||
let updateRects = 0;
|
||||
// ExtendedDesktopSize pseudo-encoding (-308), as the unsigned 32-bit
|
||||
// value the rect-header encoding field is read as.
|
||||
const EXT_DESKTOP_SIZE_U32 = (-308) >>> 0;
|
||||
|
||||
// Drain bytes from the queue into a flat buffer view
|
||||
function drainTo(n) {
|
||||
if (totalBytes < n) return null;
|
||||
const out = new Uint8Array(n);
|
||||
let off = 0;
|
||||
while (off < n) {
|
||||
const c = chunks[0];
|
||||
const take = Math.min(c.length, n - off);
|
||||
out.set(c.subarray(0, take), off);
|
||||
off += take;
|
||||
if (take === c.length) {
|
||||
chunks.shift();
|
||||
} else {
|
||||
chunks[0] = c.subarray(take);
|
||||
}
|
||||
}
|
||||
totalBytes -= n;
|
||||
return out;
|
||||
}
|
||||
|
||||
function send(data) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const arr = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
dbg('→ send [' + arr.length + 'b]: ' + hex(arr.slice(0, 32)) + (arr.length > 32 ? '…' : ''), 'send');
|
||||
ws.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
function u32be(b, o) { return ((b[o]<<24)|(b[o+1]<<16)|(b[o+2]<<8)|b[o+3])>>>0; }
|
||||
function u16be(b, o) { return ((b[o]<<8)|b[o+1])>>>0; }
|
||||
|
||||
// ── Apple-DH (security type 30) helpers ─────────────────────────────────
|
||||
// Protocol (from neatvnc apple-dh.c):
|
||||
// Server → client: generator(2) + key_size(2) + prime[key_size] + server_pub[key_size]
|
||||
// Client → server: client_pub[key_size] + aes128ecb(MD5(shared_secret), creds[128])
|
||||
// After SecurityResult=0: normal plaintext VNC (no session encryption)
|
||||
//
|
||||
// BigInt mod-pow — handles 2048-bit DH arithmetic.
|
||||
function modpow(base, exp, mod) {
|
||||
let r = 1n;
|
||||
base = base % mod;
|
||||
while (exp > 0n) {
|
||||
if (exp & 1n) r = r * base % mod;
|
||||
exp >>= 1n;
|
||||
base = base * base % mod;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
function bytesToBigInt(b) {
|
||||
let n = 0n;
|
||||
for (const byte of b) n = (n << 8n) | BigInt(byte);
|
||||
return n;
|
||||
}
|
||||
function bigIntToBytes(n, len) {
|
||||
const out = new Uint8Array(len);
|
||||
for (let i = len - 1; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; }
|
||||
return out;
|
||||
}
|
||||
|
||||
// Compact MD5 — needed because Web Crypto doesn't expose MD5.
|
||||
// Based on the RFC 1321 reference implementation, minified.
|
||||
function md5(data) {
|
||||
const b = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
const len = b.length;
|
||||
// pad message
|
||||
const padLen = ((len + 8) >>> 6 << 4) + 16;
|
||||
const m = new Uint32Array(padLen);
|
||||
for (let i = 0; i < len; i++) m[i>>2] |= b[i] << ((i&3)*8);
|
||||
m[len>>2] |= 0x80 << ((len&3)*8);
|
||||
m[padLen-2] = len*8;
|
||||
const T = new Int32Array(64);
|
||||
for (let i = 0; i < 64; i++) T[i] = (Math.abs(Math.sin(i+1)) * 0x100000000)|0;
|
||||
let [a, b2, c, d] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
|
||||
const S = [7,12,17,22, 5,9,14,20, 4,11,16,23, 6,10,15,21];
|
||||
function add(x,y){return (x+y)|0;}
|
||||
function r(v,s){return (v<<s)|(v>>>(32-s));}
|
||||
for (let i = 0; i < padLen; i += 16) {
|
||||
let [aa,bb,cc,dd] = [a,b2,c,d];
|
||||
for (let j = 0; j < 64; j++) {
|
||||
let [f, g] = j<16 ? [(bb&cc)|((~bb)&dd), j]
|
||||
: j<32 ? [(dd&bb)|((~dd)&cc), (5*j+1)%16]
|
||||
: j<48 ? [bb^cc^dd, (3*j+5)%16]
|
||||
: [cc^(bb|(~dd)), (7*j)%16];
|
||||
f = add(add(aa, f), add(m[i+g], T[j]));
|
||||
// Rotation amount: round = j>>4 (changes every 16 steps),
|
||||
// position-in-round = j%4. S is laid out as 4 rounds × 4.
|
||||
[aa,dd,cc,bb] = [dd, cc, bb, add(bb, r(f, S[(j%4)+((j>>4)*4)]))];
|
||||
}
|
||||
[a,b2,c,d] = [add(a,aa), add(b2,bb), add(c,cc), add(d,dd)];
|
||||
}
|
||||
const out = new Uint8Array(16);
|
||||
[a,b2,c,d].forEach((x,i) => {
|
||||
out[i*4]=(x)&0xff; out[i*4+1]=(x>>8)&0xff;
|
||||
out[i*4+2]=(x>>16)&0xff; out[i*4+3]=(x>>24)&0xff;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// AES-128-ECB encrypt 128 bytes using Web Crypto AES-CBC (null IV per block = ECB).
|
||||
async function aes128ecb(key16, data128) {
|
||||
const keyObj = await crypto.subtle.importKey('raw', key16, {name:'AES-CBC'}, false, ['encrypt']);
|
||||
const out = new Uint8Array(128);
|
||||
const iv = new Uint8Array(16); // zeroed IV → ECB mode for single blocks
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const enc = await crypto.subtle.encrypt({name:'AES-CBC',iv}, keyObj, data128.slice(i*16,(i+1)*16));
|
||||
out.set(new Uint8Array(enc,0,16), i*16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Apple-DH state — stored between async continuations.
|
||||
let appleDhState = null;
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function processRfb() {
|
||||
while (true) {
|
||||
if (!tryStep()) break;
|
||||
}
|
||||
}
|
||||
|
||||
function tryStep() {
|
||||
switch (state) {
|
||||
case 'version': {
|
||||
const b = drainTo(12);
|
||||
if (!b) return false;
|
||||
dbg('← server version: ' + new TextDecoder().decode(b).replace('\n','\\n'));
|
||||
// Send back same version (RFB 003.008)
|
||||
send(new TextEncoder().encode('RFB 003.008\n'));
|
||||
state = 'security-types';
|
||||
return true;
|
||||
}
|
||||
case 'security-types': {
|
||||
const b = drainTo(1);
|
||||
if (!b) return false;
|
||||
const n = b[0];
|
||||
dbg('← security-types: count=' + n + (n === 0 ? ' (server error!)' : ''));
|
||||
if (n === 0) { setStatus('server sent 0 security types', 'error'); return false; }
|
||||
const types = drainTo(n);
|
||||
if (!types) { chunks.unshift(b); totalBytes += 1; return false; }
|
||||
dbg('← security-types offered: [' + Array.from(types).join(', ') + ']');
|
||||
// Prefer type 1 (None), then type 19 (VeNCrypt — used by neatvnc/weston
|
||||
// even with --disable-transport-layer-security), else first offered.
|
||||
let prefer;
|
||||
if (types.indexOf(1) !== -1) prefer = 1; // plain None
|
||||
else if (types.indexOf(19) !== -1) prefer = 19; // VeNCrypt
|
||||
else prefer = types[0];
|
||||
// Prefer: 1 (None) → 19 (VeNCrypt) → 30 (Apple-DH)
|
||||
if (types.indexOf(1) !== -1) prefer = 1;
|
||||
else if (types.indexOf(19) !== -1) prefer = 19;
|
||||
else if (types.indexOf(30) !== -1) prefer = 30;
|
||||
else {
|
||||
dbg('no supported type in [' + Array.from(types).join(', ') + '] — need 1, 19, or 30', 'err');
|
||||
setStatus('unsupported security types: [' + Array.from(types).join(', ') + ']', 'error');
|
||||
ws.close();
|
||||
return false;
|
||||
}
|
||||
dbg('→ choosing security type ' + prefer +
|
||||
(prefer === 1 ? ' (None)' : prefer === 19 ? ' (VeNCrypt)' : ' (Apple-DH)'));
|
||||
send(new Uint8Array([prefer]));
|
||||
if (prefer === 1) state = 'security-result';
|
||||
else if (prefer === 19) state = 'vencrypt-version';
|
||||
else state = 'apple-dh-params';
|
||||
return true;
|
||||
}
|
||||
case 'security-vnc-challenge': {
|
||||
// VNC auth (type 2): we don't have the password, so send zeros.
|
||||
// This will fail for password-protected servers; fine for our
|
||||
// weston VNC which uses None via VeNCrypt.
|
||||
const b = drainTo(16);
|
||||
if (!b) return false;
|
||||
dbg('← vnc-challenge (16 bytes): ' + hex(b));
|
||||
send(new Uint8Array(16));
|
||||
state = 'security-result';
|
||||
return true;
|
||||
}
|
||||
// ── VeNCrypt (type 19) sub-handshake ───────────────────────────────
|
||||
// neatvnc (weston VNC backend) uses VeNCrypt as the outer type even
|
||||
// with --disable-transport-layer-security, offering sub-type 1 (None).
|
||||
case 'vencrypt-version': {
|
||||
// Server sends: major (u8), minor (u8) — e.g. 0, 2
|
||||
const b = drainTo(2);
|
||||
if (!b) return false;
|
||||
dbg('← VeNCrypt version: ' + b[0] + '.' + b[1]);
|
||||
// Echo same version back
|
||||
send(new Uint8Array([b[0], b[1]]));
|
||||
state = 'vencrypt-subtypes';
|
||||
return true;
|
||||
}
|
||||
case 'vencrypt-subtypes': {
|
||||
// Server sends: nSubtypes (u8), then nSubtypes × u32 sub-type ids
|
||||
const nb = drainTo(1);
|
||||
if (!nb) return false;
|
||||
const nSub = nb[0];
|
||||
dbg('← VeNCrypt nSubtypes=' + nSub);
|
||||
const raw = drainTo(nSub * 4);
|
||||
if (!raw) { chunks.unshift(nb); totalBytes += 1; return false; }
|
||||
// Build sub-type array from big-endian u32s
|
||||
const subs = [];
|
||||
for (let i = 0; i < nSub; i++) subs.push(u32be(raw, i * 4));
|
||||
dbg('← VeNCrypt sub-types: [' + subs.join(', ') + ']');
|
||||
// Prefer sub-type 1 (VeNCrypt None) — no TLS, no password.
|
||||
// Fall back to first offered.
|
||||
const sub = subs.includes(1) ? 1 : subs[0];
|
||||
dbg('→ choosing VeNCrypt sub-type ' + sub);
|
||||
// Send chosen sub-type as big-endian u32
|
||||
send(new Uint8Array([sub>>>24, (sub>>>16)&0xff, (sub>>>8)&0xff, sub&0xff]));
|
||||
state = 'vencrypt-accept';
|
||||
return true;
|
||||
}
|
||||
case 'vencrypt-accept': {
|
||||
// Server sends 1 byte: 1=accepted, 0=refused
|
||||
const b = drainTo(1);
|
||||
if (!b) return false;
|
||||
dbg('← VeNCrypt accept byte: ' + b[0] + (b[0] === 1 ? ' (ok)' : ' (REFUSED)'));
|
||||
if (b[0] !== 1) { setStatus('VeNCrypt sub-type refused', 'error'); return false; }
|
||||
// Sub-type 1 (None): proceed to SecurityResult
|
||||
state = 'security-result';
|
||||
return true;
|
||||
}
|
||||
// ── Apple-DH (type 30) ────────────────────────────────────────────
|
||||
// Server sends: generator(2 BE) + key_size(2 BE) + prime[key_size] +
|
||||
// server_pub[key_size]
|
||||
// Client sends: client_pub[key_size] + aes128ecb(MD5(shared), creds[128])
|
||||
// No session encryption after auth — plain RFB follows.
|
||||
case 'apple-dh-params': {
|
||||
const hdr = drainTo(4);
|
||||
if (!hdr) return false;
|
||||
const generator = u16be(hdr, 0);
|
||||
const keySize = u16be(hdr, 2);
|
||||
dbg('← Apple-DH: generator=' + generator + ' key_size=' + keySize);
|
||||
const rest = drainTo(keySize * 2);
|
||||
if (!rest) { chunks.unshift(hdr); totalBytes += 4; return false; }
|
||||
const prime = rest.slice(0, keySize);
|
||||
const serverPub = rest.slice(keySize);
|
||||
dbg('← Apple-DH prime[0:4]=' + hex(prime.slice(0,4)) +
|
||||
' server_pub[0:4]=' + hex(serverPub.slice(0,4)));
|
||||
|
||||
// Async DH computation — pause state machine, resume when done.
|
||||
appleDhState = { generator, keySize, prime, serverPub };
|
||||
state = 'apple-dh-wait';
|
||||
(async () => {
|
||||
try {
|
||||
const p = bytesToBigInt(appleDhState.prime);
|
||||
const g = BigInt(appleDhState.generator);
|
||||
const ks = appleDhState.keySize;
|
||||
|
||||
// Generate client private key: random ks bytes, then mod p
|
||||
const privBytes = crypto.getRandomValues(new Uint8Array(ks));
|
||||
const priv = bytesToBigInt(privBytes) % p;
|
||||
|
||||
// Client public key = g ^ priv mod p
|
||||
const clientPub = modpow(g, priv, p);
|
||||
const clientPubBytes = bigIntToBytes(clientPub, ks);
|
||||
|
||||
// Shared secret = server_pub ^ priv mod p
|
||||
const serverPubInt = bytesToBigInt(appleDhState.serverPub);
|
||||
const shared = modpow(serverPubInt, priv, p);
|
||||
const sharedBytes = bigIntToBytes(shared, ks);
|
||||
|
||||
// AES key = MD5(shared_secret)
|
||||
const aesKey = md5(sharedBytes);
|
||||
dbg('Apple-DH: shared MD5 key=' + hex(aesKey));
|
||||
|
||||
// Credentials: 64 bytes username + 64 bytes password.
|
||||
// weston's vnc_handle_auth (libweston/backend-vnc/vnc.c) does
|
||||
// getpwnam(username) and requires pw_uid == weston's own uid
|
||||
// BEFORE PAM is ever consulted — an empty/garbage username is
|
||||
// rejected outright. weston runs as root, so the username must
|
||||
// be "root". The password stays empty; pam_permit.so on the
|
||||
// weston-remote-access PAM service accepts it.
|
||||
const creds = new Uint8Array(128);
|
||||
creds.set(new TextEncoder().encode('root'), 0);
|
||||
const encCreds = await aes128ecb(aesKey, creds);
|
||||
|
||||
// Send: encrypted_creds + client_pub
|
||||
// neatvnc struct rfb_apple_dh_client_msg has encrypted_credentials
|
||||
// at offset 0 and public_key at offset 128 (flexible array after).
|
||||
const response = new Uint8Array(ks + 128);
|
||||
response.set(encCreds, 0);
|
||||
response.set(clientPubBytes, 128);
|
||||
send(response);
|
||||
dbg('→ Apple-DH response sent (' + response.length + ' bytes)', 'ok');
|
||||
|
||||
state = 'security-result';
|
||||
processRfb(); // resume state machine
|
||||
} catch(e) {
|
||||
setStatus('Apple-DH error: ' + e.message, 'error');
|
||||
}
|
||||
})();
|
||||
return false; // pause — async resumes
|
||||
}
|
||||
case 'apple-dh-wait':
|
||||
// Async handshake in progress — don't consume bytes
|
||||
return false;
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
case 'security-result': {
|
||||
const b = drainTo(4);
|
||||
if (!b) return false;
|
||||
const code = u32be(b, 0);
|
||||
dbg('← security-result: ' + code + ' (bytes: ' + hex(b) + ')' + (code === 0 ? ' ✓' : ' FAIL'), code === 0 ? 'ok' : 'err');
|
||||
if (code !== 0) { setStatus('auth failed (code ' + code + ')', 'error'); return false; }
|
||||
// ClientInit: shared flag = 1
|
||||
send(new Uint8Array([1]));
|
||||
state = 'server-init';
|
||||
return true;
|
||||
}
|
||||
case 'server-init': {
|
||||
const b = drainTo(24);
|
||||
if (!b) return false;
|
||||
// RFB ServerInit: width @ bytes 0-1, height @ bytes 2-3.
|
||||
fbW = u16be(b, 0); fbH = u16be(b, 2);
|
||||
// pixel format: bpp=b[4], depth=b[5], big-endian=b[6], true-colour=b[7]
|
||||
// red/green/blue max/shift at b[8..17]
|
||||
pixelFormat = {
|
||||
bpp: b[4], depth: b[5], bigEndian: b[6], trueColour: b[7],
|
||||
redMax: u16be(b, 8), greenMax: u16be(b, 10), blueMax: u16be(b, 12),
|
||||
redShift: b[14], greenShift: b[15], blueShift: b[16],
|
||||
bytesPerPixel: b[4] / 8,
|
||||
};
|
||||
const nameLen = u32be(b, 20);
|
||||
const nameBytes = drainTo(nameLen);
|
||||
if (!nameBytes) { chunks.unshift(b); totalBytes += 24; return false; }
|
||||
dbg('← server-init: ' + fbW + 'x' + fbH + ' bpp=' + pixelFormat.bpp, 'ok');
|
||||
canvas.width = fbW;
|
||||
canvas.height = fbH;
|
||||
relayoutCanvas();
|
||||
setStatus('connected', 'connected');
|
||||
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so the
|
||||
// server reports (and accepts) desktop-size changes. (issue #133)
|
||||
sendSetEncodings([0, -308]);
|
||||
// Request full framebuffer update
|
||||
requestUpdate(0, 0, 0, fbW, fbH);
|
||||
state = 'normal';
|
||||
return true;
|
||||
}
|
||||
case 'normal': {
|
||||
const b = drainTo(1);
|
||||
if (!b) return false;
|
||||
const msgType = b[0];
|
||||
if (msgType === 0) {
|
||||
// FramebufferUpdate: type(1) + padding(1) + nRects(2). The type
|
||||
// byte is already consumed above; hdr covers padding + nRects.
|
||||
const hdr = drainTo(3);
|
||||
if (!hdr) { chunks.unshift(b); totalBytes += 1; return false; }
|
||||
updateRects = u16be(hdr, 1);
|
||||
state = 'rect-header';
|
||||
} else if (msgType === 2) {
|
||||
// Bell: ignore
|
||||
} else if (msgType === 3) {
|
||||
// ServerCutText
|
||||
const hdr = drainTo(7);
|
||||
if (!hdr) { chunks.unshift(b); totalBytes += 1; return false; }
|
||||
const len = u32be(hdr, 3);
|
||||
const text = drainTo(len);
|
||||
if (!text) { chunks.unshift(b); totalBytes += 1 + 7; return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 'rect-header': {
|
||||
if (updateRects === 0) { state = 'normal'; requestUpdate(1, 0, 0, fbW, fbH); return true; }
|
||||
const b = drainTo(12);
|
||||
if (!b) return false;
|
||||
const x = u16be(b, 0), y = u16be(b, 2), w = u16be(b, 4), h = u16be(b, 6);
|
||||
const enc = (b[8]<<24|b[9]<<16|b[10]<<8|b[11])>>>0;
|
||||
if (enc === 0 && pixelFormat) {
|
||||
const bytes = w * h * pixelFormat.bytesPerPixel;
|
||||
const pixels = drainTo(bytes);
|
||||
if (!pixels) { chunks.unshift(b); totalBytes += 12; return false; }
|
||||
drawRaw(x, y, w, h, pixels);
|
||||
} else if (enc === EXT_DESKTOP_SIZE_U32) {
|
||||
// ExtendedDesktopSize: w,h carry the new desktop dimensions;
|
||||
// the rect body is nScreens(1) + pad(3) + nScreens×16. The
|
||||
// header's x = change reason, y = request status. (issue #133)
|
||||
const nScreens = peekByte();
|
||||
if (nScreens < 0) { chunks.unshift(b); totalBytes += 12; return false; }
|
||||
const body = drainTo(4 + nScreens * 16);
|
||||
if (!body) { chunks.unshift(b); totalBytes += 12; return false; }
|
||||
if (nScreens > 0) screenId = u32be(body, 4); // reuse the server's screen id
|
||||
if (!extDesktopSupported) {
|
||||
extDesktopSupported = true;
|
||||
matchBtn.disabled = false;
|
||||
}
|
||||
if (w && h && (w !== fbW || h !== fbH)) {
|
||||
dbg('← desktop resized to ' + w + 'x' + h
|
||||
+ ' (reason ' + x + ', status ' + y + ')', 'ok');
|
||||
fbW = w; fbH = h;
|
||||
canvas.width = w; canvas.height = h;
|
||||
relayoutCanvas();
|
||||
requestUpdate(0, 0, 0, fbW, fbH);
|
||||
}
|
||||
}
|
||||
updateRects--;
|
||||
return true;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
function drawRaw(x, y, w, h, data) {
|
||||
if (!pixelFormat || w === 0 || h === 0) return;
|
||||
const bpp = pixelFormat.bytesPerPixel;
|
||||
const img = ctx.createImageData(w, h);
|
||||
const d = img.data;
|
||||
const rs = pixelFormat.redShift, gs = pixelFormat.greenShift, bs = pixelFormat.blueShift;
|
||||
for (let i = 0, o = 0; i < w * h; i++, o += bpp) {
|
||||
let px = 0;
|
||||
if (bpp === 4) px = pixelFormat.bigEndian
|
||||
? (data[o]<<24|data[o+1]<<16|data[o+2]<<8|data[o+3])>>>0
|
||||
: (data[o+3]<<24|data[o+2]<<16|data[o+1]<<8|data[o])>>>0;
|
||||
else if (bpp === 2) px = pixelFormat.bigEndian
|
||||
? (data[o]<<8|data[o+1])>>>0 : (data[o+1]<<8|data[o])>>>0;
|
||||
else px = data[o];
|
||||
d[i*4] = (px >> rs) & pixelFormat.redMax;
|
||||
d[i*4+1] = (px >> gs) & pixelFormat.greenMax;
|
||||
d[i*4+2] = (px >> bs) & pixelFormat.blueMax;
|
||||
d[i*4+3] = 255;
|
||||
}
|
||||
ctx.putImageData(img, x, y);
|
||||
}
|
||||
|
||||
function requestUpdate(incremental, x, y, w, h) {
|
||||
const b = new Uint8Array(10);
|
||||
b[0] = 3; b[1] = incremental;
|
||||
b[2] = x>>8; b[3] = x&0xff;
|
||||
b[4] = y>>8; b[5] = y&0xff;
|
||||
b[6] = w>>8; b[7] = w&0xff;
|
||||
b[8] = h>>8; b[9] = h&0xff;
|
||||
send(b);
|
||||
}
|
||||
|
||||
// SetEncodings (msg type 2): advertise the encodings we understand.
|
||||
// Negative values are pseudo-encodings (e.g. -308 ExtendedDesktopSize).
|
||||
function sendSetEncodings(encs) {
|
||||
const b = new Uint8Array(4 + encs.length * 4);
|
||||
b[0] = 2; // message-type
|
||||
b[1] = 0; // padding
|
||||
b[2] = encs.length >> 8; b[3] = encs.length & 0xff;
|
||||
let o = 4;
|
||||
for (const e of encs) {
|
||||
const v = e >>> 0; // two's-complement for negatives
|
||||
b[o++] = (v>>24)&0xff; b[o++] = (v>>16)&0xff;
|
||||
b[o++] = (v>>8)&0xff; b[o++] = v&0xff;
|
||||
}
|
||||
send(b);
|
||||
}
|
||||
|
||||
// SetDesktopSize (msg type 251): ask the server to change the desktop
|
||||
// resolution. One screen at the origin, sized to the request. (#133)
|
||||
function sendSetDesktopSize(w, h) {
|
||||
const b = new Uint8Array(24);
|
||||
b[0] = 251; b[1] = 0; // message-type + padding
|
||||
b[2] = w>>8; b[3] = w&0xff;
|
||||
b[4] = h>>8; b[5] = h&0xff;
|
||||
b[6] = 1; b[7] = 0; // number-of-screens + padding
|
||||
// screen: id(4) x(2) y(2) width(2) height(2) flags(4)
|
||||
b[8] = (screenId>>>24)&0xff; b[9] = (screenId>>>16)&0xff;
|
||||
b[10] = (screenId>>>8)&0xff; b[11] = screenId&0xff;
|
||||
b[12] = 0; b[13] = 0; // x-position
|
||||
b[14] = 0; b[15] = 0; // y-position
|
||||
b[16] = w>>8; b[17] = w&0xff;
|
||||
b[18] = h>>8; b[19] = h&0xff;
|
||||
b[20] = 0; b[21] = 0; b[22] = 0; b[23] = 0; // flags
|
||||
send(b);
|
||||
}
|
||||
|
||||
// Peek the first unconsumed byte without draining it. -1 when empty.
|
||||
function peekByte() {
|
||||
for (const c of chunks) { if (c.length) return c[0]; }
|
||||
return -1;
|
||||
}
|
||||
|
||||
// --- Input forwarding ---
|
||||
canvas.addEventListener('mousemove', sendPointer);
|
||||
canvas.addEventListener('mousedown', sendPointer);
|
||||
canvas.addEventListener('mouseup', sendPointer);
|
||||
|
||||
function sendPointer(ev) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
// In fit mode the canvas is CSS-scaled, so the rendered rect differs
|
||||
// from the intrinsic resolution — map client coords back to fb pixels.
|
||||
const sx = r.width ? canvas.width / r.width : 1;
|
||||
const sy = r.height ? canvas.height / r.height : 1;
|
||||
const x = Math.max(0, Math.min(fbW-1, Math.round((ev.clientX - r.left) * sx)));
|
||||
const y = Math.max(0, Math.min(fbH-1, Math.round((ev.clientY - r.top) * sy)));
|
||||
let mask = 0;
|
||||
if (ev.buttons & 1) mask |= 1;
|
||||
if (ev.buttons & 4) mask |= 2;
|
||||
if (ev.buttons & 2) mask |= 4;
|
||||
const b = new Uint8Array(6);
|
||||
b[0] = 5; b[1] = mask;
|
||||
b[2] = x>>8; b[3] = x&0xff;
|
||||
b[4] = y>>8; b[5] = y&0xff;
|
||||
send(b);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (ev) => sendKey(ev, 1));
|
||||
document.addEventListener('keyup', (ev) => sendKey(ev, 0));
|
||||
|
||||
function sendKey(ev, down) {
|
||||
ev.preventDefault();
|
||||
const key = rfbKeysym(ev);
|
||||
const b = new Uint8Array(8);
|
||||
b[0] = 4; b[1] = down; b[2] = 0; b[3] = 0;
|
||||
b[4] = key>>24; b[5] = (key>>16)&0xff; b[6] = (key>>8)&0xff; b[7] = key&0xff;
|
||||
send(b);
|
||||
}
|
||||
|
||||
function rfbKeysym(ev) {
|
||||
// Map common keys to X11 keysym values
|
||||
const map = {
|
||||
'BackSpace': 0xff08, 'Tab': 0xff09, 'Enter': 0xff0d, 'Escape': 0xff1b,
|
||||
'Delete': 0xffff, 'Home': 0xff50, 'End': 0xff57, 'PageUp': 0xff55,
|
||||
'PageDown': 0xff56, 'ArrowLeft': 0xff51, 'ArrowUp': 0xff52,
|
||||
'ArrowRight': 0xff53, 'ArrowDown': 0xff54,
|
||||
'Shift': 0xffe1, 'Control': 0xffe3, 'Alt': 0xffe9, 'Meta': 0xffe7,
|
||||
'F1': 0xffbe, 'F2': 0xffbf, 'F3': 0xffc0, 'F4': 0xffc1,
|
||||
'F5': 0xffc2, 'F6': 0xffc3, 'F7': 0xffc4, 'F8': 0xffc5,
|
||||
'F9': 0xffc6, 'F10': 0xffc7, 'F11': 0xffc8, 'F12': 0xffc9,
|
||||
};
|
||||
if (map[ev.key]) return map[ev.key];
|
||||
if (ev.key.length === 1) return ev.key.codePointAt(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
98
frontend/packages/agent/src/stats.html
Normal file
98
frontend/packages/agent/src/stats.html
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>hyperhive agent — stats</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon">
|
||||
<link rel="stylesheet" href="/static/agent.css">
|
||||
<style>
|
||||
.stats-nav { display: flex; gap: 0.75rem; align-items: baseline; margin-bottom: 0.5rem; }
|
||||
.stats-nav a { color: var(--cyan); text-decoration: none; }
|
||||
.stats-nav a:hover { text-decoration: underline; }
|
||||
.window-tabs { display: flex; gap: 0.4rem; margin: 0.5rem 0 1rem; }
|
||||
.window-tabs button {
|
||||
background: var(--bg-elev); color: var(--fg);
|
||||
border: 1px solid var(--border); padding: 0.3rem 0.8rem;
|
||||
font-family: inherit; cursor: pointer;
|
||||
}
|
||||
.window-tabs button.active { background: var(--purple-dim); border-color: var(--purple); color: var(--purple); }
|
||||
.summary { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||
.summary .chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
min-width: 9rem;
|
||||
height: 3.4rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.summary .chip .label {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.summary .chip .value {
|
||||
color: var(--cyan);
|
||||
font-weight: bold;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.card h3 { margin: 0 0 0.5rem; color: var(--purple); font-size: 0.95rem; font-weight: normal; }
|
||||
.card .chart-wrap { position: relative; height: 220px; }
|
||||
.card.wide { grid-column: 1 / -1; }
|
||||
.card.wide .chart-wrap { height: 260px; }
|
||||
.empty-note { color: var(--muted); font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<pre class="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt · stats ░▒▓█▓▒░</pre>
|
||||
<div class="stats-nav">
|
||||
<a id="back-link" href="/">← live</a>
|
||||
<a id="dashboard-link" href="#">dashboard</a>
|
||||
<h2 id="title" style="margin: 0;">◆ … ◆</h2>
|
||||
</div>
|
||||
|
||||
<div class="window-tabs" id="window-tabs">
|
||||
<button data-w="1h">last 1h</button>
|
||||
<button data-w="4h">last 4h</button>
|
||||
<button data-w="24h" class="active">last 24h</button>
|
||||
<button data-w="3d">last 3d</button>
|
||||
<button data-w="7d">last 7d</button>
|
||||
<button data-w="30d">last 30d</button>
|
||||
</div>
|
||||
|
||||
<div class="summary" id="summary"></div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card wide"><h3>turns per bucket</h3><div class="chart-wrap"><canvas id="chart-turns"></canvas></div></div>
|
||||
<div class="card wide"><h3>turn duration (ms) — p50 / p95 / avg</h3><div class="chart-wrap"><canvas id="chart-duration"></canvas></div></div>
|
||||
<div class="card wide"><h3>context tokens (last inference per turn) — avg / max</h3><div class="chart-wrap"><canvas id="chart-ctx"></canvas></div></div>
|
||||
<div class="card wide"><h3>token cost per bucket (sum across inferences)</h3><div class="chart-wrap"><canvas id="chart-cost"></canvas></div></div>
|
||||
<div class="card wide"><h3>turns by model per bucket — model drives token cost</h3><div class="chart-wrap"><canvas id="chart-model"></canvas></div></div>
|
||||
<div class="card"><h3>top tools</h3><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div>
|
||||
<div class="card"><h3>wake source mix</h3><div class="chart-wrap"><canvas id="chart-wake"></canvas></div></div>
|
||||
<div class="card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js is now bundled into stats.js by esbuild (npm dep
|
||||
chart.js@4.4.4), so the page works offline / on operator
|
||||
machines without internet egress. No SRI hash to maintain. -->
|
||||
<script type="module" src="/static/stats.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
346
frontend/packages/agent/src/stats.js
Normal file
346
frontend/packages/agent/src/stats.js
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
// Per-agent stats page. Fetches /api/state for the title + dashboard link
|
||||
// once on load, then /api/stats?window=... for the chart data — re-fetches
|
||||
// when the operator clicks a window tab.
|
||||
|
||||
import Chart from 'chart.js/auto';
|
||||
|
||||
// Expose for the IIFE below — pre-split this was a window global from
|
||||
// the jsDelivr CDN script tag. esbuild now bundles chart.js into
|
||||
// stats.js; once the IIFE opens up we can use the imported `Chart`
|
||||
// directly.
|
||||
window.Chart = Chart;
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
const palette = {
|
||||
bg: cssVar('--bg'),
|
||||
bgElev: cssVar('--bg-elev'),
|
||||
fg: cssVar('--fg'),
|
||||
muted: cssVar('--muted'),
|
||||
purple: cssVar('--purple'),
|
||||
cyan: cssVar('--cyan'),
|
||||
pink: cssVar('--pink'),
|
||||
amber: cssVar('--amber'),
|
||||
green: cssVar('--green'),
|
||||
red: cssVar('--red'),
|
||||
border: cssVar('--border'),
|
||||
};
|
||||
// Distinct hues for categorical charts (top tools / wake mix / result mix).
|
||||
const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber,
|
||||
palette.green, palette.red, '#94e2d5', '#f9e2af',
|
||||
'#74c7ec', '#b4befe'];
|
||||
|
||||
// Apply Catppuccin defaults globally so each Chart inherits without per-call
|
||||
// overrides. Chart.js v4 reads these on chart construction.
|
||||
Chart.defaults.color = palette.fg;
|
||||
Chart.defaults.borderColor = palette.border;
|
||||
Chart.defaults.font.family = '"JetBrains Mono", "Fira Code", monospace';
|
||||
Chart.defaults.font.size = 11;
|
||||
Chart.defaults.plugins.legend.labels.color = palette.fg;
|
||||
|
||||
const charts = {};
|
||||
let currentWindow = '24h';
|
||||
|
||||
function fmtMs(ms) {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '0';
|
||||
if (ms < 1000) return ms.toFixed(0) + 'ms';
|
||||
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's';
|
||||
}
|
||||
|
||||
function fmtInt(n) {
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
return new Intl.NumberFormat().format(Math.round(n));
|
||||
}
|
||||
|
||||
function bucketLabel(ts, bucketSecs) {
|
||||
const d = new Date(ts * 1000);
|
||||
if (bucketSecs >= 86400) {
|
||||
return d.toISOString().slice(5, 10); // MM-DD
|
||||
}
|
||||
return d.toISOString().slice(11, 16); // HH:MM
|
||||
}
|
||||
|
||||
function destroy(name) {
|
||||
if (charts[name]) {
|
||||
charts[name].destroy();
|
||||
delete charts[name];
|
||||
}
|
||||
}
|
||||
|
||||
function paintEmpty(canvasId, msg) {
|
||||
destroy(canvasId);
|
||||
const cv = document.getElementById(canvasId);
|
||||
if (!cv) return;
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
ctx.fillStyle = palette.muted;
|
||||
ctx.font = '12px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(msg, cv.width / 2, cv.height / 2);
|
||||
}
|
||||
|
||||
function renderSummary(s) {
|
||||
const root = document.getElementById('summary');
|
||||
root.replaceChildren();
|
||||
const chips = [
|
||||
['turns', fmtInt(s.turn_count)],
|
||||
['avg duration', fmtMs(s.duration_summary.avg_ms)],
|
||||
['p50 duration', fmtMs(s.duration_summary.p50_ms)],
|
||||
['p95 duration', fmtMs(s.duration_summary.p95_ms)],
|
||||
['window', s.window],
|
||||
];
|
||||
for (const [label, value] of chips) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
const l = document.createElement('span');
|
||||
l.className = 'label';
|
||||
l.textContent = label;
|
||||
const v = document.createElement('span');
|
||||
v.className = 'value';
|
||||
v.textContent = value;
|
||||
chip.append(l, v);
|
||||
root.append(chip);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTurnsChart(s) {
|
||||
const id = 'chart-turns';
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
const data = s.buckets.map((b) => b.turn_count);
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'turns',
|
||||
data,
|
||||
backgroundColor: palette.purple,
|
||||
borderColor: palette.purple,
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderDurationChart(s) {
|
||||
const id = 'chart-duration';
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
const ds = (label, color, key) => ({
|
||||
label, data: s.buckets.map((b) => b[key]),
|
||||
borderColor: color, backgroundColor: color + '33',
|
||||
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
||||
});
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
ds('p50', palette.cyan, 'p50_duration_ms'),
|
||||
ds('p95', palette.pink, 'p95_duration_ms'),
|
||||
ds('avg', palette.amber, 'avg_duration_ms'),
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { callback: (v) => fmtMs(v) },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderCtxChart(s) {
|
||||
const id = 'chart-ctx';
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'avg ctx',
|
||||
data: s.buckets.map((b) => b.avg_ctx_tokens),
|
||||
borderColor: palette.cyan,
|
||||
backgroundColor: palette.cyan + '33',
|
||||
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
||||
},
|
||||
{
|
||||
label: 'max ctx',
|
||||
data: s.buckets.map((b) => b.max_ctx_tokens),
|
||||
borderColor: palette.amber,
|
||||
backgroundColor: palette.amber + '33',
|
||||
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderCostChart(s) {
|
||||
const id = 'chart-cost';
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
// Stacked bars: cache_read (cheap) / cache_creation / input / output.
|
||||
// Highlights "what's actually getting billed at full rate" vs cache hits.
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens),
|
||||
backgroundColor: palette.muted },
|
||||
{ label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens),
|
||||
backgroundColor: palette.cyan },
|
||||
{ label: 'input', data: s.buckets.map((b) => b.input_tokens),
|
||||
backgroundColor: palette.amber },
|
||||
{ label: 'output', data: s.buckets.map((b) => b.output_tokens),
|
||||
backgroundColor: palette.pink },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { stacked: true, grid: { color: palette.border } },
|
||||
y: { stacked: true, beginAtZero: true,
|
||||
grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderModelChart(s) {
|
||||
const id = 'chart-model';
|
||||
destroy(id);
|
||||
const models = s.models || [];
|
||||
if (!models.length) { paintEmpty(id, 'no turns in window'); return; }
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
// One stacked series per model. Model choice drives token cost,
|
||||
// so this lines up against the cost chart above it.
|
||||
const datasets = models.map((m, i) => ({
|
||||
label: m,
|
||||
data: s.buckets.map((b) => (b.model_counts && b.model_counts[m]) || 0),
|
||||
backgroundColor: wheel[i % wheel.length],
|
||||
}));
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } },
|
||||
scales: {
|
||||
x: { stacked: true, grid: { color: palette.border } },
|
||||
y: { stacked: true, beginAtZero: true,
|
||||
grid: { color: palette.border }, ticks: { precision: 0 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderKeyCount(canvasId, items, emptyMsg) {
|
||||
destroy(canvasId);
|
||||
if (!items || items.length === 0) {
|
||||
paintEmpty(canvasId, emptyMsg);
|
||||
return;
|
||||
}
|
||||
const labels = items.map((kc) => kc.key);
|
||||
const data = items.map((kc) => kc.count);
|
||||
const colors = items.map((_, i) => wheel[i % wheel.length]);
|
||||
charts[canvasId] = new Chart(document.getElementById(canvasId), {
|
||||
type: 'doughnut',
|
||||
data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
renderSummary(s);
|
||||
if (s.turn_count === 0) {
|
||||
paintEmpty('chart-turns', 'no turns in window');
|
||||
paintEmpty('chart-duration', 'no turns in window');
|
||||
paintEmpty('chart-ctx', 'no turns in window');
|
||||
paintEmpty('chart-cost', 'no turns in window');
|
||||
paintEmpty('chart-model', 'no turns in window');
|
||||
paintEmpty('chart-tools', 'no tool calls');
|
||||
paintEmpty('chart-wake', 'no wakes');
|
||||
paintEmpty('chart-result', 'no results');
|
||||
return;
|
||||
}
|
||||
renderTurnsChart(s);
|
||||
renderDurationChart(s);
|
||||
renderCtxChart(s);
|
||||
renderCostChart(s);
|
||||
renderModelChart(s);
|
||||
renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls');
|
||||
renderKeyCount('chart-wake', s.wake_mix, 'no wakes');
|
||||
renderKeyCount('chart-result', s.result_mix, 'no results');
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const resp = await fetch('/api/stats?window=' + encodeURIComponent(currentWindow));
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const snap = await resp.json();
|
||||
render(snap);
|
||||
} catch (e) {
|
||||
document.getElementById('summary').textContent = 'stats fetch failed: ' + e;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIdentity() {
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
if (!resp.ok) return;
|
||||
const s = await resp.json();
|
||||
document.title = 'stats · ' + s.label;
|
||||
document.getElementById('title').textContent = '◆ ' + s.label + ' ◆';
|
||||
const dl = document.getElementById('dashboard-link');
|
||||
dl.href = 'http://' + window.location.hostname + ':' + s.dashboard_port + '/';
|
||||
} catch (_) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
function bindTabs() {
|
||||
const tabs = document.getElementById('window-tabs');
|
||||
tabs.addEventListener('click', (ev) => {
|
||||
const btn = ev.target.closest('button[data-w]');
|
||||
if (!btn) return;
|
||||
currentWindow = btn.dataset.w;
|
||||
for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn);
|
||||
loadStats();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
bindTabs();
|
||||
loadIdentity();
|
||||
loadStats();
|
||||
});
|
||||
})();
|
||||
85
frontend/packages/dashboard/build.mjs
Normal file
85
frontend/packages/dashboard/build.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// esbuild build for @hive/dashboard. Output layout (`dist/`):
|
||||
//
|
||||
// dist/index.html served by the Rust router at GET /
|
||||
// dist/flow.html served at GET /flow.html
|
||||
// dist/static/tabs.js /index.html entry — tab renderers +
|
||||
// tab routing + refreshState
|
||||
// dist/static/flow.js /flow.html entry — broker terminal +
|
||||
// operator inbox + @-mention composer
|
||||
// dist/static/{tabs,flow}.js.map source map siblings
|
||||
// dist/static/dashboard.css served at /static/dashboard.css
|
||||
// (@import resolved from @hive/shared)
|
||||
//
|
||||
// Both JS entries inline `./common.js` (DOM helpers, Panel singleton,
|
||||
// NOTIF, path linkification) — esbuild dedupes the shared module
|
||||
// between bundles. The Rust binary mounts `dist/` as a
|
||||
// `tower_http::ServeDir` fallback; the layout above keeps every URL
|
||||
// the HTML files reference reachable without rewriting paths in the
|
||||
// HTML.
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = (p) => resolve(here, 'src', p);
|
||||
const dist = (p) => resolve(here, 'dist', p);
|
||||
const staticDir = (p) => resolve(here, 'dist', 'static', p);
|
||||
|
||||
rmSync(dist(''), { recursive: true, force: true });
|
||||
mkdirSync(staticDir(''), { recursive: true });
|
||||
|
||||
// Bundle both JS entries. ES-module output, browser target, no minify
|
||||
// (line-aligned source aids debugging; minification belongs in a later
|
||||
// follow-up once asset sizes warrant it). esbuild writes each entry
|
||||
// to `static/<name>.js` based on the entryPoint basename.
|
||||
await build({
|
||||
entryPoints: [src('tabs.js'), src('flow.js')],
|
||||
outdir: staticDir(''),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['es2022'],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker
|
||||
// scripts run in a different global (`self` is the worker scope, no
|
||||
// `window`) so they can't be inlined into tabs.js / flow.js. Output is
|
||||
// at `static/stream-worker.js`; common.js's `openStream` references
|
||||
// `/static/stream-worker.js` as the SharedWorker URL. `format: 'iife'`
|
||||
// matches the classic-script load (`new SharedWorker(url, name)` with
|
||||
// no `{ type: 'module' }`); Firefox is the #448 target and module
|
||||
// SharedWorker support there is patchy, so keeping the worker as a
|
||||
// classic script + IIFE bundle is the compatible default. argus nit
|
||||
// on #453: if a future contributor adds an `import` to this bundle,
|
||||
// the IIFE format will surface it as a build error rather than
|
||||
// silently shipping broken code.
|
||||
await build({
|
||||
entryPoints: [src('stream-worker.js')],
|
||||
outdir: staticDir(''),
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
platform: 'browser',
|
||||
target: ['es2022'],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
// Bundle the CSS — esbuild resolves @import including the package
|
||||
// re-exports from @hive/shared.
|
||||
await build({
|
||||
entryPoints: [src('dashboard.css')],
|
||||
outfile: staticDir('dashboard.css'),
|
||||
bundle: true,
|
||||
loader: { '.css': 'css' },
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
for (const html of ['index.html', 'flow.html']) {
|
||||
copyFileSync(src(html), dist(html));
|
||||
}
|
||||
|
||||
console.log('dashboard build ok →', dist(''));
|
||||
14
frontend/packages/dashboard/package.json
Normal file
14
frontend/packages/dashboard/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "@hive/dashboard",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "hive-c0re dashboard SPA. Bundled by esbuild into a static dist; served by the hive-c0re Rust binary at runtime via tower_http::ServeDir.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node ./build.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hive/shared": "*",
|
||||
"marked": "4.3.0"
|
||||
}
|
||||
}
|
||||
591
frontend/packages/dashboard/src/common.js
Normal file
591
frontend/packages/dashboard/src/common.js
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
// Shared dashboard helpers — extracted from the original monolithic
|
||||
// dashboard JS as step 1 of the #406 split. These bits are used by
|
||||
// both the tab dashboard (index.html) and the flow page (flow.html):
|
||||
// pure DOM helpers, the side-panel singleton, the OS-notification
|
||||
// module, and the path-link / file-preview infrastructure for the
|
||||
// side panel.
|
||||
//
|
||||
// Each page now has its own entry point — `./tabs.js` for index.html,
|
||||
// `./flow.js` for flow.html — and both import from here directly
|
||||
// (#406 steps 2 + 3 complete; #406 closed).
|
||||
|
||||
import { linkify as termLinkify } from '@hive/shared/terminal.js';
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
|
||||
export const fmtAgeSecs = (s) => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m`
|
||||
: s < 86400 ? `${Math.floor(s/3600)}h` : `${Math.floor(s/86400)}d`;
|
||||
|
||||
export const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
||||
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
||||
);
|
||||
|
||||
export const el = (tag, attrs = {}, ...children) => {
|
||||
const e = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k === 'html') e.innerHTML = v;
|
||||
else if (k.startsWith('data-')) e.setAttribute(k, v);
|
||||
else e.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) {
|
||||
if (c == null) continue;
|
||||
e.append(c.nodeType ? c : document.createTextNode(c));
|
||||
}
|
||||
return e;
|
||||
};
|
||||
|
||||
export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => {
|
||||
const f = el('form', {
|
||||
method: 'POST', action, class: 'inline', 'data-async': '',
|
||||
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}),
|
||||
// Endpoints whose mutation fires a DashboardEvent (and whose
|
||||
// derived store applies it live) opt out of the post-submit
|
||||
// /api/state refetch. See the async-form handler.
|
||||
...(opts.noRefresh ? { 'data-no-refresh': '' } : {}),
|
||||
});
|
||||
for (const [name, value] of Object.entries(extra)) {
|
||||
f.append(el('input', { type: 'hidden', name, value }));
|
||||
}
|
||||
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel));
|
||||
return f;
|
||||
};
|
||||
|
||||
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` stay in tabs.js
|
||||
// for now — each has display-specific phrasing ("X running", "X ago")
|
||||
// tied to its caller, so they don't generalise cleanly. We can lift
|
||||
// them when a second consumer needs the same shape.
|
||||
|
||||
// ─── shared-worker SSE pipe (#448) ──────────────────────────────────────
|
||||
// Returns an EventSource-shaped object backed by a SharedWorker that
|
||||
// holds ONE upstream `new EventSource(url)` and fans events out to
|
||||
// every connected tab. Replaces direct `new EventSource(url)` at the
|
||||
// dashboard's two consumer sites (tabs.js inline + flow.js via
|
||||
// terminal.js's `streamFactory` option) so N hyperhive tabs share
|
||||
// ONE backend connection — way under the browser's per-host
|
||||
// connection cap, immune to per-tab throttling that drops the SSE
|
||||
// when Firefox suspends background tabs.
|
||||
//
|
||||
// Graceful fallback to direct EventSource on environments without
|
||||
// SharedWorker (some embedded browsers, some Safari versions). The
|
||||
// per-tab connection cost is the same as today — no regression.
|
||||
//
|
||||
// The page consumer uses the returned object like a regular
|
||||
// EventSource: assign `onmessage` / `onopen` / `onerror`. `.close()`
|
||||
// tells the worker to drop the subscription; the worker closes the
|
||||
// upstream EventSource when the last subscriber leaves.
|
||||
const SHARED_WORKER_PATH = '/static/stream-worker.js';
|
||||
const SHARED_WORKER_NAME = 'hyperhive-stream';
|
||||
|
||||
// One SharedWorker port per page, reused by all openStream calls on
|
||||
// that page. Invalidated on `pagehide` so a bfcache restore picks up
|
||||
// a fresh port (the cached one may have been collected if all other
|
||||
// tabs closed while this page was frozen — argus nit on #453).
|
||||
let _sharedPort = null;
|
||||
function makeSharedPort() {
|
||||
if (typeof SharedWorker === 'undefined') return null;
|
||||
try {
|
||||
const sw = new SharedWorker(SHARED_WORKER_PATH, SHARED_WORKER_NAME);
|
||||
sw.port.start();
|
||||
return sw.port;
|
||||
} catch (err) {
|
||||
console.warn('SharedWorker unavailable, falling back to direct EventSource:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function getSharedPort() {
|
||||
if (!_sharedPort) _sharedPort = makeSharedPort();
|
||||
return _sharedPort;
|
||||
}
|
||||
|
||||
// Registry of live subscriptions on this page. Keyed by url so a
|
||||
// second openStream call for the same URL (would only happen on a
|
||||
// hypothetical multi-consumer page) attaches to the existing route
|
||||
// rather than overlapping. Each entry caches the route function so
|
||||
// bfcache-restore re-bind can re-attach it to the fresh port.
|
||||
//
|
||||
// Today's pages only call openStream once with one URL; the registry
|
||||
// shape just keeps the bfcache-restore path correct if that changes
|
||||
// (e.g. /index.html later subscribing to two streams).
|
||||
const _activeSubs = new Map();
|
||||
|
||||
// One-shot wiring of the page-wide lifecycle hooks: on bfcache
|
||||
// freeze (`pagehide { persisted: true }`) we unsubscribe so the
|
||||
// worker can close the upstream when the last live subscriber
|
||||
// leaves; on bfcache restore (`pageshow { persisted: true }`) we
|
||||
// invalidate the cached port (it may be dead if all other tabs
|
||||
// closed during the freeze) and re-attach every active subscription
|
||||
// to a fresh port. argus nit on #453: without this, the consumer's
|
||||
// onmessage stays bound but no events flow after a bfcache restore.
|
||||
let _lifecycleBound = false;
|
||||
function bindLifecycleOnce() {
|
||||
if (_lifecycleBound) return;
|
||||
_lifecycleBound = true;
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (!_sharedPort) return;
|
||||
for (const url of _activeSubs.keys()) {
|
||||
try { _sharedPort.postMessage({ kind: 'unsubscribe', url }); }
|
||||
catch { /* port dead — worker side already cleaned up */ }
|
||||
}
|
||||
// Drop port routes too; the bfcache-restore path will re-add
|
||||
// them on a fresh port. Leaving stale routes on a dead port
|
||||
// would just keep a closure alive without cost, but cleaning
|
||||
// up keeps the registry shape honest.
|
||||
for (const sub of _activeSubs.values()) {
|
||||
try { _sharedPort.removeEventListener('message', sub.route); }
|
||||
catch { /* same */ }
|
||||
}
|
||||
_sharedPort = null;
|
||||
});
|
||||
window.addEventListener('pageshow', (ev) => {
|
||||
if (!ev.persisted) return; // cold load — openStream just bound listeners
|
||||
if (!_activeSubs.size) return;
|
||||
const port = getSharedPort();
|
||||
if (!port) return; // SharedWorker really gone; fallback already in place
|
||||
for (const [url, sub] of _activeSubs) {
|
||||
sub.target.readyState = 0; // CONNECTING — the worker will fire 'open'
|
||||
port.addEventListener('message', sub.route);
|
||||
try { port.postMessage({ kind: 'subscribe', url }); }
|
||||
catch { /* port dead immediately — skip */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function openStream(url) {
|
||||
const port = getSharedPort();
|
||||
if (!port) return new EventSource(url);
|
||||
bindLifecycleOnce();
|
||||
|
||||
// Build an EventSource-shaped facade so consumer code is unchanged.
|
||||
// `target.onmessage` / `onopen` / `onerror` are assigned by the
|
||||
// consumer; the routing function below forwards events received
|
||||
// from the worker (filtered by url, since one port can multiplex
|
||||
// multiple subscriptions).
|
||||
const target = {
|
||||
onmessage: null,
|
||||
onopen: null,
|
||||
onerror: null,
|
||||
readyState: 0, // CONNECTING
|
||||
close() {
|
||||
const p = _sharedPort;
|
||||
if (p) {
|
||||
try { p.postMessage({ kind: 'unsubscribe', url }); }
|
||||
catch { /* port dead */ }
|
||||
try { p.removeEventListener('message', route); }
|
||||
catch { /* same */ }
|
||||
}
|
||||
_activeSubs.delete(url);
|
||||
},
|
||||
};
|
||||
const route = (e) => {
|
||||
const m = e.data;
|
||||
if (!m || m.url !== url) return;
|
||||
if (m.kind === 'open') {
|
||||
target.readyState = 1; // OPEN
|
||||
if (target.onopen) {
|
||||
try { target.onopen({ target }); }
|
||||
catch (err) { console.error('openStream onopen threw', err); }
|
||||
}
|
||||
} else if (m.kind === 'message') {
|
||||
if (target.onmessage) {
|
||||
try { target.onmessage({ data: m.data, target }); }
|
||||
catch (err) { console.error('openStream onmessage threw', err); }
|
||||
}
|
||||
} else if (m.kind === 'error') {
|
||||
if (target.onerror) {
|
||||
try { target.onerror({ target }); }
|
||||
catch (err) { console.error('openStream onerror threw', err); }
|
||||
}
|
||||
}
|
||||
};
|
||||
_activeSubs.set(url, { target, route });
|
||||
port.addEventListener('message', route);
|
||||
port.postMessage({ kind: 'subscribe', url });
|
||||
return target;
|
||||
}
|
||||
|
||||
// ─── side panel ─────────────────────────────────────────────────────────
|
||||
// Singleton drawer that swipes in from the right. Long content
|
||||
// (file previews, approval diffs, journald logs, applied config)
|
||||
// opens here via `Panel.open(title, node)` instead of expanding
|
||||
// inline. Body is swapped on each open; closing just slides out so
|
||||
// the content stays visible through the transition.
|
||||
export const Panel = (() => {
|
||||
let root = null;
|
||||
let titleEl = null;
|
||||
let bodyEl = null;
|
||||
let drawer = null;
|
||||
/** Owner key set by `openNamed` (e.g. 'inbox'). `refresh(name, …)`
|
||||
* is a no-op when the current owner doesn't match, so live
|
||||
* updates can re-render an open view without grabbing focus
|
||||
* from a closed one (or from an unrelated open view like a
|
||||
* diff drill-in). Untyped calls via `open(title, content)`
|
||||
* clear the owner — the legacy file-preview/diff/log paths
|
||||
* don't participate in named-refresh semantics. */
|
||||
let owner = null;
|
||||
function ensure() {
|
||||
if (!root) {
|
||||
root = $('side-panel');
|
||||
titleEl = $('side-panel-title');
|
||||
bodyEl = $('side-panel-body');
|
||||
drawer = root && root.querySelector('.side-panel-drawer');
|
||||
}
|
||||
return root != null;
|
||||
}
|
||||
function open(title, content) {
|
||||
if (!ensure()) return;
|
||||
owner = null;
|
||||
titleEl.textContent = title;
|
||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
||||
root.classList.add('open');
|
||||
root.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
function openNamed(name, title, content) {
|
||||
open(title, content);
|
||||
owner = name;
|
||||
}
|
||||
function refresh(name, title, content) {
|
||||
if (!ensure()) return;
|
||||
if (owner !== name) return;
|
||||
titleEl.textContent = title;
|
||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
||||
}
|
||||
function close() {
|
||||
if (!ensure()) return;
|
||||
owner = null;
|
||||
root.classList.remove('open');
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
// #451: drag-to-resize the drawer's width. Listens on a thin
|
||||
// hit-strip glued to the drawer's left edge; mousedown captures
|
||||
// pointermove + pointerup on the document so the drag continues
|
||||
// even if the cursor strays outside the 6px handle band. Width
|
||||
// persists to localStorage so it survives page reload. The CSS
|
||||
// clamps the value (min-width: 320px, max-width: 96vw) — drop
|
||||
// unparseable / out-of-range stored values silently.
|
||||
const WIDTH_KEY = 'hyperhive:side-panel-width';
|
||||
const WIDTH_MIN = 320;
|
||||
function clampWidth(w) {
|
||||
const max = Math.floor(window.innerWidth * 0.96);
|
||||
return Math.max(WIDTH_MIN, Math.min(max, w));
|
||||
}
|
||||
function applyStoredWidth() {
|
||||
if (!drawer) return;
|
||||
const raw = (() => {
|
||||
try { return localStorage.getItem(WIDTH_KEY); }
|
||||
catch { return null; }
|
||||
})();
|
||||
if (!raw) return;
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
||||
drawer.style.setProperty('--side-panel-w', clampWidth(parsed) + 'px');
|
||||
}
|
||||
function bindResize() {
|
||||
if (!drawer) return;
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'side-panel-resize';
|
||||
handle.setAttribute('role', 'separator');
|
||||
handle.setAttribute('aria-orientation', 'vertical');
|
||||
handle.setAttribute('aria-label', 'drag to resize side panel');
|
||||
handle.title = 'drag to resize';
|
||||
drawer.prepend(handle);
|
||||
let dragging = false;
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
document.body.classList.add('side-panel-resizing');
|
||||
// Capture so we keep getting pointermove even when the cursor
|
||||
// outpaces the handle band (drag-fast-then-pause loses the
|
||||
// handle's :hover state otherwise).
|
||||
try { handle.setPointerCapture(e.pointerId); } catch { /* legacy */ }
|
||||
});
|
||||
document.addEventListener('pointermove', (e) => {
|
||||
if (!dragging) return;
|
||||
// Drawer is anchored to the right edge — width = viewport - pointer X.
|
||||
const w = clampWidth(window.innerWidth - e.clientX);
|
||||
drawer.style.setProperty('--side-panel-w', w + 'px');
|
||||
});
|
||||
function stopDrag() {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
document.body.classList.remove('side-panel-resizing');
|
||||
// Persist the final width. Read the actual rendered width
|
||||
// rather than re-deriving so the stored value matches what
|
||||
// the operator saw at mouseup.
|
||||
const w = drawer.getBoundingClientRect().width;
|
||||
try { localStorage.setItem(WIDTH_KEY, String(Math.round(w))); }
|
||||
catch { /* localStorage unavailable — width is session-only */ }
|
||||
}
|
||||
document.addEventListener('pointerup', stopDrag);
|
||||
document.addEventListener('pointercancel', stopDrag);
|
||||
// Re-clamp on viewport resize so a persisted width that exceeds
|
||||
// 96vw doesn't push the drawer off-screen after a window shrink.
|
||||
window.addEventListener('resize', () => {
|
||||
if (dragging) return;
|
||||
const cur = drawer.getBoundingClientRect().width;
|
||||
const clamped = clampWidth(cur);
|
||||
if (clamped !== Math.round(cur)) {
|
||||
drawer.style.setProperty('--side-panel-w', clamped + 'px');
|
||||
}
|
||||
});
|
||||
}
|
||||
function bind() {
|
||||
if (!ensure()) return;
|
||||
$('side-panel-close').addEventListener('click', close);
|
||||
$('side-panel-backdrop').addEventListener('click', close);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && root.classList.contains('open')) close();
|
||||
});
|
||||
applyStoredWidth();
|
||||
bindResize();
|
||||
}
|
||||
return { open, openNamed, refresh, close, bind };
|
||||
})();
|
||||
|
||||
// ─── path linkification ─────────────────────────────────────────────────
|
||||
// Agents constantly drop pointer strings into messages + question
|
||||
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
|
||||
// PATH_RE patterns becomes a clickable anchor; clicking expands an
|
||||
// inline <details> with the file's contents, fetched lazily from
|
||||
// /api/state-file. The legacy in-container `/state/...` prefix is
|
||||
// deliberately not matched — it's ambiguous from the host's
|
||||
// perspective (we'd need to know which agent the message is about
|
||||
// to translate it). Prefer `/agents/<name>/state/...` in agent
|
||||
// outputs and the link will resolve.
|
||||
async function fetchStateFile(path) {
|
||||
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
|
||||
return text;
|
||||
}
|
||||
// A 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
|
||||
// `renderRendered()` produces the rendered-tab node fresh on each
|
||||
// switch; `plainText` backs the raw tab; `plainLabel` names it.
|
||||
function buildTabbedPreview(renderRendered, plainText, plainLabel) {
|
||||
const tabs = el('div', { class: 'diff-base-tabs' });
|
||||
const host = el('div', { class: 'preview-host' });
|
||||
function show(mode) {
|
||||
for (const b of tabs.children) {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
}
|
||||
host.replaceChildren(mode === 'plain'
|
||||
? el('pre', { class: 'path-preview-body' }, plainText)
|
||||
: renderRendered());
|
||||
}
|
||||
for (const [mode, label] of [['rendered', 'rendered'], ['plain', plainLabel]]) {
|
||||
const b = el('button',
|
||||
{ type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
|
||||
b.addEventListener('click', () => show(mode));
|
||||
tabs.append(b);
|
||||
}
|
||||
show('rendered');
|
||||
return el('div', {}, tabs, host);
|
||||
}
|
||||
// Rendered <img> for an SVG, loaded via an <img> data: URI —
|
||||
// <img>-loaded SVG runs in the browser's secure static mode (no
|
||||
// scripts, no external fetches), so an untrusted SVG from an
|
||||
// agent's state dir can't execute code in the dashboard.
|
||||
function svgImage(text) {
|
||||
const img = el('img', { class: 'img-preview', alt: 'SVG preview' });
|
||||
img.addEventListener('error', () => {
|
||||
img.replaceWith(el('div', { class: 'meta' },
|
||||
'(could not render — see the source tab)'));
|
||||
});
|
||||
img.src = 'data:image/svg+xml,' + encodeURIComponent(text);
|
||||
return img;
|
||||
}
|
||||
// Marked-rendered markdown node (raw text fallback if `marked`
|
||||
// failed to load).
|
||||
function mdNode(text) {
|
||||
const div = el('div', { class: 'md' });
|
||||
if (window.marked && typeof window.marked.parse === 'function') {
|
||||
window.marked.setOptions({ breaks: true, gfm: true });
|
||||
div.innerHTML = window.marked.parse(text);
|
||||
// marked autolinks URLs but leaves them same-tab — open externally
|
||||
// so a click never navigates away from the dashboard. (issue #233)
|
||||
div.querySelectorAll('a[href]').forEach((a) => {
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
});
|
||||
} else {
|
||||
div.textContent = text;
|
||||
}
|
||||
return div;
|
||||
}
|
||||
// Raster image extensions the preview renders as an <img> pointed
|
||||
// straight at /api/state-file (served binary with a real
|
||||
// content-type). SVG is handled on the text path instead.
|
||||
const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
|
||||
// Lazy-load `path` from /api/state-file into the side panel.
|
||||
// Markdown + SVG get a rendered/plain tabbed view; raster images
|
||||
// render as an <img>; every other file stays raw text in a <pre>.
|
||||
async function openFilePanel(path) {
|
||||
if (RASTER_RE.test(path)) {
|
||||
const img = el('img', { class: 'img-preview', alt: path });
|
||||
img.addEventListener('error', () => {
|
||||
img.replaceWith(el('pre', { class: 'path-preview-body' },
|
||||
'(could not load image — it may be missing or over the preview size cap)'));
|
||||
});
|
||||
img.src = '/api/state-file?path=' + encodeURIComponent(path);
|
||||
Panel.open('↳ ' + path, img);
|
||||
return;
|
||||
}
|
||||
const isMd = /\.(md|markdown)$/i.test(path);
|
||||
const isSvg = /\.svg$/i.test(path);
|
||||
const view = el('div');
|
||||
view.textContent = '(fetching…)';
|
||||
Panel.open('↳ ' + path, view);
|
||||
try {
|
||||
const text = await fetchStateFile(path);
|
||||
if (isSvg) {
|
||||
view.replaceChildren(buildTabbedPreview(() => svgImage(text), text, 'source'));
|
||||
} else if (isMd) {
|
||||
view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain'));
|
||||
} else {
|
||||
view.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
|
||||
}
|
||||
} catch (e) {
|
||||
view.textContent = 'error: ' + (e.message || e);
|
||||
}
|
||||
}
|
||||
export function makePathLink(path) {
|
||||
const anchor = el('a', {
|
||||
href: '#', class: 'path-link', title: 'open ' + path + ' in panel',
|
||||
}, path);
|
||||
anchor.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
openFilePanel(path);
|
||||
});
|
||||
return anchor;
|
||||
}
|
||||
// Append a plain-text run, with bare http(s) URLs turned into clickable
|
||||
// links via the shared terminal linkifier.
|
||||
export function appendText(parent, s) {
|
||||
if (!s) return;
|
||||
parent.appendChild(termLinkify(s));
|
||||
}
|
||||
// Append `text` to `parent` as a mix of text nodes + path anchors.
|
||||
// `refs` is the server-attached `file_refs` array (verified-file
|
||||
// tokens that appear in `text`); each occurrence of a ref becomes a
|
||||
// clickable anchor that opens the file in the side panel. Anything
|
||||
// not in `refs` stays plain text. No client-side regex, no probe
|
||||
// endpoint — the server saw the body first and made the call. When
|
||||
// `refs` is empty/missing we just emit plain text.
|
||||
export function appendLinkified(parent, text, refs) {
|
||||
if (text == null) return;
|
||||
const str = String(text);
|
||||
const tokens = (refs || []).slice();
|
||||
if (!tokens.length) {
|
||||
appendText(parent, str);
|
||||
return;
|
||||
}
|
||||
// Walk the string left-to-right, at each step looking for the
|
||||
// next occurrence of any token. Longest-first tie-break so a
|
||||
// ref like `/agents/foo/state/x.md` wins over a (hypothetical)
|
||||
// shorter token that prefixes it. O(text * refs) worst case;
|
||||
// refs is bounded server-side to whatever fits in a body, so
|
||||
// this stays cheap.
|
||||
tokens.sort((a, b) => b.length - a.length);
|
||||
let i = 0;
|
||||
while (i < str.length) {
|
||||
let bestStart = -1;
|
||||
let bestToken = null;
|
||||
for (const t of tokens) {
|
||||
const idx = str.indexOf(t, i);
|
||||
if (idx === -1) continue;
|
||||
if (bestStart === -1 || idx < bestStart || (idx === bestStart && t.length > bestToken.length)) {
|
||||
bestStart = idx;
|
||||
bestToken = t;
|
||||
}
|
||||
}
|
||||
if (bestStart === -1) {
|
||||
appendText(parent, str.slice(i));
|
||||
break;
|
||||
}
|
||||
if (bestStart > i) {
|
||||
appendText(parent, str.slice(i, bestStart));
|
||||
}
|
||||
parent.appendChild(makePathLink(bestToken));
|
||||
i = bestStart + bestToken.length;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── browser notifications ──────────────────────────────────────────────
|
||||
// Fires OS notifications on three operator-bound signals:
|
||||
// - new approval landed in the queue
|
||||
// - new operator question queued (ask, target IS NULL)
|
||||
// - broker message sent `to: "operator"`
|
||||
// Permission grant is per-browser; a localStorage "muted" toggle lets
|
||||
// the operator silence without revoking. Secure-context only (HTTPS /
|
||||
// localhost) — on other origins the API is unavailable and we hide
|
||||
// the controls.
|
||||
export const NOTIF = (() => {
|
||||
const supported = typeof Notification !== 'undefined';
|
||||
const MUTED_KEY = 'hyperhive.notify.muted';
|
||||
const isMuted = () => localStorage.getItem(MUTED_KEY) === '1';
|
||||
const setMuted = (v) => v
|
||||
? localStorage.setItem(MUTED_KEY, '1')
|
||||
: localStorage.removeItem(MUTED_KEY);
|
||||
function renderControls() {
|
||||
const enable = $('notif-enable');
|
||||
const mute = $('notif-mute');
|
||||
const unmute = $('notif-unmute');
|
||||
const status = $('notif-status');
|
||||
if (!enable || !mute || !unmute || !status) return;
|
||||
if (!supported) {
|
||||
enable.hidden = mute.hidden = unmute.hidden = true;
|
||||
status.hidden = false;
|
||||
status.textContent = 'notifications unsupported in this browser';
|
||||
return;
|
||||
}
|
||||
const perm = Notification.permission;
|
||||
enable.hidden = perm === 'granted';
|
||||
mute.hidden = perm !== 'granted' || isMuted();
|
||||
unmute.hidden = perm !== 'granted' || !isMuted();
|
||||
status.hidden = perm !== 'denied';
|
||||
if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
|
||||
}
|
||||
function bind() {
|
||||
const enable = $('notif-enable');
|
||||
const mute = $('notif-mute');
|
||||
const unmute = $('notif-unmute');
|
||||
if (!supported || !enable || !mute || !unmute) return;
|
||||
enable.addEventListener('click', async () => {
|
||||
await Notification.requestPermission();
|
||||
renderControls();
|
||||
});
|
||||
mute.addEventListener('click', () => { setMuted(true); renderControls(); });
|
||||
unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
|
||||
renderControls();
|
||||
}
|
||||
function show(title, body, tag) {
|
||||
if (!supported) {
|
||||
console.debug('notify: Notification API not supported');
|
||||
return;
|
||||
}
|
||||
if (Notification.permission !== 'granted') {
|
||||
console.debug('notify: permission not granted', Notification.permission);
|
||||
return;
|
||||
}
|
||||
if (isMuted()) {
|
||||
console.debug('notify: muted');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Per-event tag so distinct messages stack instead of
|
||||
// collapsing into one slot. Caller passes a unique tag per
|
||||
// notification kind/id; we don't fall back to 'hyperhive'
|
||||
// because that one tag would replace itself on every fire.
|
||||
const n = new Notification(title, {
|
||||
body,
|
||||
tag: tag || ('hyperhive:' + Date.now()),
|
||||
});
|
||||
n.onclick = () => { window.focus(); n.close(); };
|
||||
console.debug('notify: shown', title, 'tag=', tag);
|
||||
} catch (err) {
|
||||
console.warn('notification show failed', err);
|
||||
}
|
||||
}
|
||||
return { bind, show, renderControls };
|
||||
})();
|
||||
1879
frontend/packages/dashboard/src/dashboard.css
Normal file
1879
frontend/packages/dashboard/src/dashboard.css
Normal file
File diff suppressed because it is too large
Load diff
120
frontend/packages/dashboard/src/flow.html
Normal file
120
frontend/packages/dashboard/src/flow.html
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>hyperhive // FL0W</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/dashboard.css">
|
||||
</head>
|
||||
<body class="flow-shell">
|
||||
|
||||
<!-- Fixed-overlay chrome — just the tab strip (#389 follow-up:
|
||||
slug moved to the dashboard's page footer; the flow page is a
|
||||
full-viewport terminal with no normal-flow footer position, so
|
||||
the slug simply doesn't appear here). The operator can still
|
||||
switch tabs from the flow page without navigating back; FL0W is
|
||||
the current page, SW4RM / Y3R C4LL / SYST3M cross-link to the
|
||||
dashboard with the matching hash. -->
|
||||
<header class="dashboard-chrome flow-chrome" id="flow-header">
|
||||
<nav class="tabbar" id="tabbar" role="tablist">
|
||||
<a class="tab" href="/#swarm" role="tab" data-tab="swarm">
|
||||
<span class="tab-label">◆ SW4RM ◆</span>
|
||||
<span class="tab-count" id="tab-count-swarm" hidden></span>
|
||||
</a>
|
||||
<a class="tab" href="/#call" role="tab" data-tab="call">
|
||||
<span class="tab-label">◆ Y3R C4LL ◆</span>
|
||||
<span class="tab-count tab-count-attn" id="tab-count-call" hidden></span>
|
||||
</a>
|
||||
<a class="tab" href="/#system" role="tab" data-tab="system">
|
||||
<span class="tab-label">◆ SYST3M ◆</span>
|
||||
<span class="tab-count" id="tab-count-system" hidden></span>
|
||||
</a>
|
||||
<a class="tab" href="/#schedules" role="tab" data-tab="schedules">
|
||||
<span class="tab-label">◆ SCH3DUL3S ◆</span>
|
||||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||
</a>
|
||||
<a class="tab tab-link active" id="tab-flow" href="/flow.html"
|
||||
aria-current="page"
|
||||
title="all-agents chat — you are here">
|
||||
<span class="tab-label">◆ FL0W ◆</span>
|
||||
<span class="tab-count" id="tab-count-flow" hidden></span>
|
||||
</a>
|
||||
|
||||
<!-- Notif controls cohabit with the tabs (always-on chrome).
|
||||
Same IDs as on the dashboard so the shared NOTIF binding
|
||||
(from common.js, imported by both tabs.js and flow.js)
|
||||
picks them up unchanged. -->
|
||||
<div id="notif-row" class="notif-row">
|
||||
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
|
||||
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
|
||||
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
|
||||
<span id="notif-status" class="meta" hidden></span>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Operator inbox flyout trigger — count + click → side panel
|
||||
(singleton, declared below). Hidden until the inbox is non-
|
||||
empty. Mirrors the agent page's pill pattern (#362). -->
|
||||
<button type="button" id="inbox-pill" class="flow-pill" hidden
|
||||
title="open operator inbox">
|
||||
<span class="flow-pill-icon" aria-hidden="true">📬</span>
|
||||
<span class="flow-pill-label">inbox</span>
|
||||
<span class="flow-pill-count" id="inbox-pill-count">0</span>
|
||||
</button>
|
||||
|
||||
<!-- Main content: the full-viewport terminal. Padded for the
|
||||
overlay header + composer so the first/last rows stay
|
||||
reachable. -->
|
||||
<main class="flow-main">
|
||||
<div class="terminal-wrap">
|
||||
<div id="msgflow" class="live terminal"><div class="meta">connecting…</div></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
|
||||
as the header — symmetric framing, terminal goes edge-to-edge
|
||||
between them. -->
|
||||
<footer class="flow-composer">
|
||||
<div id="op-compose" class="op-compose">
|
||||
<span id="op-compose-prompt" class="op-compose-prompt">@—></span>
|
||||
<textarea id="op-compose-input" class="op-compose-input"
|
||||
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
|
||||
rows="1" autocomplete="off"></textarea>
|
||||
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Inbox rendered offscreen — kept in the DOM so flow.js's
|
||||
renderInbox keeps working unchanged. The pill click handler
|
||||
opens the side panel which displays a clone of the list. The
|
||||
legacy section heading would otherwise be visible; hidden
|
||||
here. -->
|
||||
<div id="inbox-section" class="flow-inbox-headless" hidden>
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- Slide-in side panel. Singleton — JS swaps the title + body
|
||||
and toggles `.open`. On this page only used to surface the
|
||||
operator inbox flyout; the dashboard's other panel uses
|
||||
(approval diffs, file previews, logs) don't apply here. -->
|
||||
<div id="side-panel" class="side-panel" aria-hidden="true">
|
||||
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
|
||||
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
|
||||
aria-labelledby="side-panel-title">
|
||||
<header class="side-panel-head">
|
||||
<span class="side-panel-title" id="side-panel-title"></span>
|
||||
<button type="button" class="side-panel-close" id="side-panel-close"
|
||||
title="close (esc)">✕</button>
|
||||
</header>
|
||||
<div class="side-panel-body" id="side-panel-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Flow-specific bundle (#406 step 2). Contains the broker
|
||||
terminal init, the operator-inbox derived store, the inbox
|
||||
pill flyout, and the @-mention composer. Tab renderers etc.
|
||||
live in `/static/tabs.js` which /flow.html doesn't load. -->
|
||||
<script type="module" src="/static/flow.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
440
frontend/packages/dashboard/src/flow.js
Normal file
440
frontend/packages/dashboard/src/flow.js
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
// /flow.html entry point (#406 step 2 — flow-specific split from the
|
||||
// previous combined entry; #406 step 3 renamed that combined entry
|
||||
// from `app.js` to `tabs.js`).
|
||||
//
|
||||
// Owns the full-page broker terminal, the operator-inbox derived store
|
||||
// (populated from the broker stream), the inbox pill flyout, and the
|
||||
// @-mention compose box. Pulls shared infrastructure (DOM helpers, side
|
||||
// panel, OS notifications, path linkification) from `./common.js`.
|
||||
//
|
||||
// Does NOT contain the dashboard's tab renderers, mutation-event
|
||||
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
|
||||
// /index.html. The flow page runs purely on the broker stream + an
|
||||
// initial /api/state fetch (compose autocomplete needs the live
|
||||
// container list).
|
||||
|
||||
import { create as termCreate } from '@hive/shared/terminal.js';
|
||||
import {
|
||||
$, el,
|
||||
Panel, NOTIF,
|
||||
appendLinkified,
|
||||
openStream,
|
||||
} from './common.js';
|
||||
|
||||
(() => {
|
||||
Panel.bind();
|
||||
NOTIF.bind();
|
||||
|
||||
// ─── operator inbox (derived from the broker message stream) ───────────
|
||||
// No longer shipped on `/api/state.operator_inbox`. The broker
|
||||
// terminal feeds this via `onAnyEvent` — backfill from
|
||||
// `/dashboard/history` populates on load, live SSE keeps it current.
|
||||
// Newest-first to match the previous behaviour.
|
||||
const INBOX_LIMIT = 50;
|
||||
const operatorInbox = [];
|
||||
function inboxAppendFromEvent(ev) {
|
||||
if (ev.kind !== 'sent' || ev.to !== 'operator') return false;
|
||||
operatorInbox.unshift({
|
||||
from: ev.from,
|
||||
body: ev.body,
|
||||
at: ev.at,
|
||||
file_refs: ev.file_refs || [],
|
||||
});
|
||||
if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT;
|
||||
return true;
|
||||
}
|
||||
function buildInboxListNode() {
|
||||
if (!operatorInbox.length) return el('p', { class: 'empty' }, 'no messages');
|
||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
const ul = el('ul', { class: 'inbox' });
|
||||
for (const m of operatorInbox) {
|
||||
const li = el('li');
|
||||
const body = el('span', { class: 'msg-body' });
|
||||
appendLinkified(body, m.body, m.file_refs);
|
||||
li.append(
|
||||
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
|
||||
el('span', { class: 'msg-from' }, m.from), ' ',
|
||||
el('span', { class: 'msg-sep' }, '→ '),
|
||||
body,
|
||||
);
|
||||
ul.append(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
function renderInbox() {
|
||||
// Flow page surfaces inbox as a pill that opens the side-panel
|
||||
// flyout. Pill is hidden when empty; click handler below opens
|
||||
// the panel with the freshest list. If the panel is already
|
||||
// showing the inbox view, refresh its body in place so live
|
||||
// messages land without a re-open.
|
||||
const pill = $('inbox-pill');
|
||||
const pillCount = $('inbox-pill-count');
|
||||
if (pillCount) pillCount.textContent = String(operatorInbox.length);
|
||||
if (pill) pill.hidden = operatorInbox.length === 0;
|
||||
Panel.refresh('inbox', 'inbox · ' + operatorInbox.length, buildInboxListNode());
|
||||
}
|
||||
|
||||
// Wire the inbox pill to open the side-panel flyout with the
|
||||
// operator inbox.
|
||||
const inboxPill = $('inbox-pill');
|
||||
if (inboxPill) {
|
||||
inboxPill.addEventListener('click', () => {
|
||||
Panel.openNamed('inbox', 'inbox · ' + operatorInbox.length,
|
||||
buildInboxListNode());
|
||||
});
|
||||
}
|
||||
|
||||
// ─── local containers cache (for compose autocomplete) ──────────────────
|
||||
// The compose box's @-mention completion suggests known agent names.
|
||||
// /index.html (tabs.js) maintains the canonical `containersState`
|
||||
// from /api/state + SSE; here we keep a small local mirror updated
|
||||
// by the same `container_state_changed` / `container_removed` events
|
||||
// the dashboard would handle.
|
||||
const flowContainers = new Map();
|
||||
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ });
|
||||
|
||||
// ─── message flow: shared terminal pane ────────────────────────────────
|
||||
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
|
||||
// What stays here is the broker-message renderer + the page-local
|
||||
// side effects (banner pulse, inbox refresh on operator-bound
|
||||
// traffic, OS notifications).
|
||||
(() => {
|
||||
const flow = $('msgflow');
|
||||
if (!flow) return;
|
||||
flow.innerHTML = '';
|
||||
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
||||
// Pulse the page banner whenever a broker event lands. (Note:
|
||||
// post-#389 the `.banner` lives in the dashboard's <footer>, not
|
||||
// in the flow page chrome — `pulseBanner` no-ops on /flow.html
|
||||
// since there's no element to find. Kept for parity if a future
|
||||
// chrome change reintroduces a banner.)
|
||||
const banner = document.querySelector('.banner');
|
||||
let bannerOffTimer = null;
|
||||
function pulseBanner() {
|
||||
if (!banner) return;
|
||||
banner.classList.add('active');
|
||||
if (bannerOffTimer) clearTimeout(bannerOffTimer);
|
||||
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
|
||||
}
|
||||
// Map of broker row id → rendered row element. Lets reply rows add
|
||||
// a visual "↳ in reply to" indicator that links back to the parent.
|
||||
// Bounded by the history window (~200 msgs from /dashboard/history),
|
||||
// well within normal memory.
|
||||
const msgRowMap = new Map();
|
||||
|
||||
function renderMsg(ev, api, glyph) {
|
||||
const isReply = ev.in_reply_to != null;
|
||||
const cls = 'msgrow ' + ev.kind + (isReply ? ' msg-reply' : '');
|
||||
const row = api.row(cls, '');
|
||||
// Build via DOM so path anchors stay live + escape rules are
|
||||
// automatic (text nodes don't need esc()).
|
||||
const ts = document.createElement('span');
|
||||
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'msg-arrow'; arrow.textContent = glyph;
|
||||
const from = document.createElement('span');
|
||||
from.className = 'msg-from'; from.textContent = ev.from;
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'msg-sep'; sep.textContent = '→';
|
||||
const to = document.createElement('span');
|
||||
to.className = 'msg-to'; to.textContent = ev.to;
|
||||
const body = document.createElement('span');
|
||||
body.className = 'msg-body';
|
||||
appendLinkified(body, ev.body, ev.file_refs);
|
||||
// Reply thread indicator: a small "↳ reply to <from>" hint that
|
||||
// shows which message this is responding to. If we have the parent
|
||||
// in our row map, clicking scrolls it into view.
|
||||
if (isReply) {
|
||||
const replyTag = document.createElement('span');
|
||||
replyTag.className = 'msg-reply-tag';
|
||||
const parentRow = msgRowMap.get(ev.in_reply_to);
|
||||
if (parentRow) {
|
||||
const link = document.createElement('a');
|
||||
link.href = '#';
|
||||
link.textContent = '↳ reply';
|
||||
link.title = 'scroll to parent message';
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
parentRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
parentRow.classList.add('msg-highlight');
|
||||
setTimeout(() => parentRow.classList.remove('msg-highlight'), 1500);
|
||||
});
|
||||
replyTag.append(link);
|
||||
} else {
|
||||
replyTag.textContent = '↳ reply';
|
||||
}
|
||||
row.prepend(replyTag);
|
||||
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
||||
} else {
|
||||
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
||||
}
|
||||
// Register this row so future replies can reference it.
|
||||
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
||||
}
|
||||
// Anchor the `↓ N new` pill in `.flow-main` (NOT the default
|
||||
// `log.parentElement` = `.terminal-wrap`). `.terminal-wrap`
|
||||
// applies `backdrop-filter`, which creates a CSS stacking
|
||||
// context — the pill's z-index would otherwise be trapped
|
||||
// inside and clipped under the fixed composer (issue #375).
|
||||
// `.flow-main` has no backdrop-filter / stacking-context
|
||||
// creators, so the pill's z-index reaches the root and floats
|
||||
// above the composer.
|
||||
const flowMain = document.querySelector('.flow-main');
|
||||
termCreate({
|
||||
logEl: flow,
|
||||
pillAnchor: flowMain,
|
||||
historyUrl: '/dashboard/history',
|
||||
// #408: server-side filter — only the kinds this page actually
|
||||
// renders or routes (sent/delivered → broker terminal,
|
||||
// container_state_changed/_removed → local autocomplete cache).
|
||||
// Backend (#499) pre-parses the allow-list at subscribe time so
|
||||
// the per-frame hot path is one HashSet::contains and the
|
||||
// JSON-serialise is skipped entirely on irrelevant kinds. The
|
||||
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
|
||||
// since it routes every mutation kind into its derived stores.
|
||||
streamUrl: '/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
|
||||
// #448: route through the SharedWorker so this page's SSE shares
|
||||
// a single backend connection with /index.html (and any other
|
||||
// open hyperhive tab). Worker keys on the full URL (incl.
|
||||
// query string), so the filtered subscribe is its own upstream
|
||||
// — won't accidentally share with tabs.js's wider subscribe.
|
||||
streamFactory: openStream,
|
||||
renderers: {
|
||||
sent: (ev, api) => renderMsg(ev, api, '→'),
|
||||
delivered: (ev, api) => renderMsg(ev, api, '✓'),
|
||||
// Maintain the local containers cache from the same stream
|
||||
// (compose autocomplete reads from `flowContainers`). The
|
||||
// dashboard's tab renderers aren't on this page, so we don't
|
||||
// need to dispatch to applyContainerStateChanged etc. — just
|
||||
// keep the autocomplete list current.
|
||||
container_state_changed: (ev) => {
|
||||
if (ev.container && ev.container.name) {
|
||||
flowContainers.set(ev.container.name, ev.container);
|
||||
}
|
||||
},
|
||||
container_removed: (ev) => { flowContainers.delete(ev.name); },
|
||||
// Drop every other mutation kind silently — without this
|
||||
// they'd fall through to the terminal module's default
|
||||
// renderer and clutter the log with JSON dumps. The dashboard
|
||||
// tabs handle these on /index.html via tabs.js.
|
||||
_default: () => {},
|
||||
},
|
||||
// Both history backfill and live frames flow through here, so the
|
||||
// inbox section ends up populated correctly on first paint and
|
||||
// updated thereafter — no /api/state refetch needed for inbox
|
||||
// freshness.
|
||||
onAnyEvent: (ev /* , { fromHistory } */) => {
|
||||
if (inboxAppendFromEvent(ev)) renderInbox();
|
||||
},
|
||||
// Re-sync the local containers cache on every SSE (re)connect.
|
||||
// Live mutation events that fired during a disconnect window
|
||||
// are never replayed, so without this the compose autocomplete
|
||||
// could drift stale (issue #163). We don't try to recover
|
||||
// missed broker rows here — operator inbox briefly stales on
|
||||
// reconnect; HiveTerminal's history-replay covers the next
|
||||
// page load.
|
||||
onStreamOpen: () => {
|
||||
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
flowContainers.clear();
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
}).catch(() => {});
|
||||
},
|
||||
onLiveEvent: (ev) => {
|
||||
pulseBanner();
|
||||
if (ev.kind === 'sent' && ev.to === 'operator') {
|
||||
NOTIF.show(
|
||||
'◆ ' + ev.from + ' → operator',
|
||||
String(ev.body || '').slice(0, 200),
|
||||
// Unique-per-arrival tag so a burst stacks instead of
|
||||
// overwriting itself in the OS notification center.
|
||||
'hyperhive:msg:' + ev.at + ':' + Math.random().toString(36).slice(2, 6),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
||||
// ─── compose: @-mention with sticky recipient ───────────────────────────
|
||||
(() => {
|
||||
const input = $('op-compose-input');
|
||||
const prompt = $('op-compose-prompt');
|
||||
const suggest = $('op-compose-suggest');
|
||||
if (!input || !prompt || !suggest) return;
|
||||
const STORAGE_KEY = 'hyperhive:op-compose:to';
|
||||
let stickyTo = localStorage.getItem(STORAGE_KEY) || '';
|
||||
let suggestActive = -1;
|
||||
function renderPrompt() {
|
||||
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
|
||||
}
|
||||
function knownAgents() {
|
||||
// Read live from the flow-local containers cache so newly-spawned
|
||||
// agents become addressable without a manual reload.
|
||||
// Broker uses the literal recipient `manager` for the manager's
|
||||
// inbox, not the container name `hm1nd`.
|
||||
const names = Array.from(flowContainers.values())
|
||||
.map((c) => (c.is_manager ? 'manager' : c.name));
|
||||
// `*` fans out to every registered agent (server-side
|
||||
// broadcast_send).
|
||||
names.unshift('*');
|
||||
return names;
|
||||
}
|
||||
function autosize() {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = `${input.scrollHeight}px`;
|
||||
}
|
||||
/// Parse "@name body…" — return {to, body} when the input opens
|
||||
/// with a known @-mention, otherwise null.
|
||||
function parseAddressed(raw) {
|
||||
const m = raw.match(/^@([\w*-]+)\s+([\s\S]+)$/);
|
||||
if (!m) return null;
|
||||
return { to: m[1], body: m[2] };
|
||||
}
|
||||
function hideSuggest() {
|
||||
suggest.hidden = true;
|
||||
suggest.innerHTML = '';
|
||||
suggestActive = -1;
|
||||
}
|
||||
function renderSuggest(matches) {
|
||||
suggest.innerHTML = '';
|
||||
if (!matches.length) { hideSuggest(); return; }
|
||||
for (let i = 0; i < matches.length; i += 1) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'item' + (i === suggestActive ? ' active' : '');
|
||||
item.textContent = '@' + matches[i];
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
applySuggestion(matches[i]);
|
||||
});
|
||||
suggest.append(item);
|
||||
}
|
||||
suggest.hidden = false;
|
||||
}
|
||||
function applySuggestion(name) {
|
||||
// Replace the partial @-token at the start with the full name.
|
||||
const v = input.value;
|
||||
const m = v.match(/^@(\S*)/);
|
||||
if (m) {
|
||||
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, '');
|
||||
} else {
|
||||
input.value = `@${name} ` + v;
|
||||
}
|
||||
hideSuggest();
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
autosize();
|
||||
}
|
||||
function updateSuggest() {
|
||||
const v = input.value;
|
||||
// Only suggest when an @-token sits at the very start of the
|
||||
// input — switching recipient is always "redirect this whole
|
||||
// line." Mid-message @-mentions stay literal.
|
||||
const m = v.match(/^@(\S*)/);
|
||||
if (!m) { hideSuggest(); return; }
|
||||
const partial = m[1].toLowerCase();
|
||||
const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial));
|
||||
if (!matches.length) { hideSuggest(); return; }
|
||||
if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0;
|
||||
renderSuggest(matches);
|
||||
}
|
||||
async function submit() {
|
||||
const raw = input.value.trim();
|
||||
if (!raw) return;
|
||||
let to;
|
||||
let body;
|
||||
const addressed = parseAddressed(raw);
|
||||
if (addressed) {
|
||||
to = addressed.to;
|
||||
body = addressed.body.trim();
|
||||
} else if (stickyTo) {
|
||||
to = stickyTo;
|
||||
body = raw;
|
||||
} else {
|
||||
flashError('no recipient — start with @name to address a message');
|
||||
return;
|
||||
}
|
||||
if (!body) return;
|
||||
const fd = new FormData();
|
||||
fd.append('to', to);
|
||||
fd.append('body', body);
|
||||
input.disabled = true;
|
||||
try {
|
||||
// /op-send returns 200. The SSE channel carries the resulting
|
||||
// MessageEvent → the terminal renders the sent row + the
|
||||
// inbox updates on its own; no /api/state refetch needed.
|
||||
const resp = await fetch('/op-send', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(fd),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
flashError(`send failed: http ${resp.status}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
flashError(`send failed: ${err}`);
|
||||
return;
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
stickyTo = to;
|
||||
localStorage.setItem(STORAGE_KEY, to);
|
||||
input.value = '';
|
||||
autosize();
|
||||
renderPrompt();
|
||||
input.focus();
|
||||
}
|
||||
function flashError(msg) {
|
||||
const flow = $('msgflow');
|
||||
if (!flow) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'msgrow meta';
|
||||
row.textContent = msg;
|
||||
flow.insertBefore(row, flow.firstChild);
|
||||
}
|
||||
input.addEventListener('input', () => { autosize(); updateSuggest(); });
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (!suggest.hidden) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
const items = suggest.querySelectorAll('.item');
|
||||
suggestActive = (suggestActive + 1) % items.length;
|
||||
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
const items = suggest.querySelectorAll('.item');
|
||||
suggestActive = (suggestActive - 1 + items.length) % items.length;
|
||||
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
|
||||
const active = suggest.querySelector('.item.active');
|
||||
if (active) {
|
||||
applySuggestion(active.textContent.slice(1));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
hideSuggest();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
// Defer so a click on a suggestion item (mousedown) lands first.
|
||||
setTimeout(hideSuggest, 100);
|
||||
});
|
||||
renderPrompt();
|
||||
autosize();
|
||||
})();
|
||||
})();
|
||||
226
frontend/packages/dashboard/src/index.html
Normal file
226
frontend/packages/dashboard/src/index.html
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>hyperhive // h1ve-c0re</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/dashboard.css">
|
||||
</head>
|
||||
<body class="dashboard-shell">
|
||||
|
||||
<!-- Sticky chrome — just the tab strip now (#389 follow-up: the
|
||||
"WE ARE THE WIRED" slug moved out of chrome entirely and lives
|
||||
at the page footer below `<main>`; chrome is navigation only).
|
||||
Tabs route via the URL hash so F5 / back-button / shared links
|
||||
keep you on the same view. JS owns the actual show/hide; this
|
||||
is just the menu. -->
|
||||
<header class="dashboard-chrome">
|
||||
<nav class="tabbar" id="tabbar" role="tablist">
|
||||
<a class="tab" id="tab-swarm" href="#swarm" role="tab"
|
||||
aria-controls="tab-pane-swarm"
|
||||
data-tab="swarm">
|
||||
<span class="tab-label">◆ SW4RM ◆</span>
|
||||
<span class="tab-count" id="tab-count-swarm" hidden></span>
|
||||
</a>
|
||||
<a class="tab" id="tab-call" href="#call" role="tab"
|
||||
aria-controls="tab-pane-call"
|
||||
data-tab="call">
|
||||
<span class="tab-label">◆ Y3R C4LL ◆</span>
|
||||
<span class="tab-count tab-count-attn" id="tab-count-call" hidden></span>
|
||||
</a>
|
||||
<a class="tab" id="tab-system" href="#system" role="tab"
|
||||
aria-controls="tab-pane-system"
|
||||
data-tab="system">
|
||||
<span class="tab-label">◆ SYST3M ◆</span>
|
||||
<span class="tab-count" id="tab-count-system" hidden></span>
|
||||
</a>
|
||||
<!-- SCH3DUL3S (#459): scheduled-prompts surface. List of
|
||||
queued schedules + an operator-direct creation form.
|
||||
Count pill mirrors the active (non-cancelled) schedule
|
||||
count; hidden when zero. -->
|
||||
<a class="tab" id="tab-schedules" href="#schedules" role="tab"
|
||||
aria-controls="tab-pane-schedules"
|
||||
data-tab="schedules">
|
||||
<span class="tab-label">◆ SCH3DUL3S ◆</span>
|
||||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||
</a>
|
||||
|
||||
<!-- FL0W is its own page (`/flow.html`), not a tab — per
|
||||
operator @ #369#issuecomment-3437 ("yes terminal can be a
|
||||
separate page"). The link lives in the tab strip so it
|
||||
reads as a peer surface; clicking navigates rather than
|
||||
swapping panes in place. Count pill mirrors the dashboard's
|
||||
operator-inbox length and is hidden when zero. -->
|
||||
<a class="tab tab-link" id="tab-flow" href="/flow.html"
|
||||
title="open the all-agents chat in a dedicated full-page terminal">
|
||||
<span class="tab-label">◆ FL0W ◆ →</span>
|
||||
<span class="tab-count" id="tab-count-flow" hidden></span>
|
||||
</a>
|
||||
|
||||
<!-- Notification controls live in the chrome (always-on
|
||||
ergonomics; not tab-specific). -->
|
||||
<div id="notif-row" class="notif-row">
|
||||
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
|
||||
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
|
||||
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
|
||||
<span id="notif-status" class="meta" hidden></span>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Tab panes. Exactly one is `.tab-pane-active` at a time;
|
||||
JS toggles based on the URL hash + `hashchange` events. -->
|
||||
<main class="dashboard-main">
|
||||
|
||||
<!-- SW4RM: the swarm itself. Container cards (the central thing
|
||||
the operator looks at) and rebuild queue / cascade visualisation
|
||||
that drives them. The tab label itself reads SW4RM, so the
|
||||
inline C0NTAINERS h2 heading + divider would be redundant —
|
||||
dropped per #385. -->
|
||||
<section class="tab-pane" id="tab-pane-swarm"
|
||||
role="tabpanel" aria-labelledby="tab-swarm">
|
||||
<div id="containers-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Y3R C4LL: things blocked on operator decision. Approvals +
|
||||
questions read as the same concept ("something is waiting on
|
||||
you"); both surface their full bodies inline so the operator
|
||||
can decide without leaving the pane. -->
|
||||
<section class="tab-pane" id="tab-pane-call"
|
||||
role="tabpanel" aria-labelledby="tab-call">
|
||||
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<div id="approvals-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<div id="questions-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SYST3M: passive / rare-interaction state. Meta inputs (lock
|
||||
bumps), rebuild queue (watch only), kept state from previous
|
||||
tombstoned agents. Queued reminders moved to the SCH3DUL3S
|
||||
tab in #460 — they're conceptually "fire X at time Y" too,
|
||||
just self-scheduled by agents instead of operator-set.
|
||||
Headings stay; the per-section content auto-compresses to a
|
||||
one-line summary when empty (separate JS toggle). -->
|
||||
<section class="tab-pane" id="tab-pane-system"
|
||||
role="tabpanel" aria-labelledby="tab-system">
|
||||
<h2>◆ M3T4 1NPUTS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual <code>rebuilt</code> system event.</p>
|
||||
<div id="meta-inputs-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<h2>◆ R3BU1LD QU3U3 ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p>
|
||||
<div id="rebuild-queue-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<h2>◆ K3PT ST4T3 ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<div id="tombstones-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SCH3DUL3S (#459): scheduled prompts. operator-direct creation
|
||||
form at the top (POST /api/schedules, no approval gate), live
|
||||
schedules list below (GET /api/schedules) with per-target
|
||||
last-fired timestamps + result, plus per-target / whole-row
|
||||
cancel buttons (POST /api/schedules/{id}/cancel). #444 backend
|
||||
doesn't emit a SchedulesChanged dashboard event yet, so the
|
||||
list re-fetches on tab activation + after each form/cancel
|
||||
submit. Live SSE wiring is the future PR C. -->
|
||||
<section class="tab-pane" id="tab-pane-schedules"
|
||||
role="tabpanel" aria-labelledby="tab-schedules">
|
||||
<h2>◆ N3W SCH3DUL3 ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">queue a prompt to fire at a future time. operator-direct (no approval gate); recurring when an interval is set. targets are any known agent name or <code>operator</code> / <code>manager</code>.</p>
|
||||
<div id="schedule-new-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<h2>◆ QU3U3D SCH3DUL3S ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">all schedules currently in the table. expand each card to see per-target firing history. cancel a single target with the row button or the whole schedule with <code>✕ cancel all</code>.</p>
|
||||
<div id="schedules-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- QU3U3D R3M1ND3RS (#460): self-scheduled agent reminders.
|
||||
Moved here from the SYST3M tab so the operator has one
|
||||
place for everything that fires at a future time —
|
||||
operator-set schedules above, agent-self reminders here.
|
||||
Backed by GET /api/reminders; refresh handled by
|
||||
refreshReminders() (called from refreshState). -->
|
||||
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.</p>
|
||||
<div id="reminders-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FL0W: lives on its own page now (`/flow.html`). The
|
||||
message-flow + inbox + compose DOM only exists there — when
|
||||
tabs.js boots on this page the corresponding renderers
|
||||
no-op silently (each guard is `if (!el) return`). -->
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<pre class="banner banner-thin">░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p>▲△▲ <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
|
||||
</footer>
|
||||
|
||||
<!-- Slide-in detail panel. Long content (clicked file previews,
|
||||
approval diffs, journald logs, applied config) opens here
|
||||
instead of expanding inline. Singleton — JS swaps the title +
|
||||
body and toggles `.open`. Lives outside the tab panes so it
|
||||
overlays any active tab. -->
|
||||
<div id="side-panel" class="side-panel" aria-hidden="true">
|
||||
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
|
||||
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
|
||||
aria-labelledby="side-panel-title">
|
||||
<header class="side-panel-head">
|
||||
<span class="side-panel-title" id="side-panel-title"></span>
|
||||
<button type="button" class="side-panel-close" id="side-panel-close"
|
||||
title="close (esc)">✕</button>
|
||||
</header>
|
||||
<div class="side-panel-body" id="side-panel-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Selection action bar (#443). Sticky-bottom strip that slides
|
||||
into view when one or more agent cards is selected (click the
|
||||
icon to toggle). Shows the selection count + actions that
|
||||
apply to ALL selected; disabled-with-tooltip for actions that
|
||||
don't (mara picked option B). Hidden when selection is empty. -->
|
||||
<div id="selection-bar" class="selection-bar" hidden role="toolbar"
|
||||
aria-label="bulk agent actions">
|
||||
<span class="selection-count" id="selection-count"></span>
|
||||
<span class="selection-names" id="selection-names"></span>
|
||||
<span class="selection-actions" id="selection-actions"></span>
|
||||
<button type="button" class="btn selection-clear" id="selection-clear"
|
||||
title="clear selection (esc)">✕ clear</button>
|
||||
</div>
|
||||
|
||||
<!-- Single bundled entry (#406 step 3 — renamed from app.js to
|
||||
tabs.js since this bundle is the dashboard *tabs* surface only;
|
||||
flow.html has its own flow.js bundle). esbuild folds
|
||||
@hive/shared/terminal.js and the marked npm package into
|
||||
tabs.js; load order is preserved by the module bundler. -->
|
||||
<script type="module" src="/static/tabs.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
110
frontend/packages/dashboard/src/stream-worker.js
Normal file
110
frontend/packages/dashboard/src/stream-worker.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// SharedWorker that holds ONE EventSource per stream URL and fans
|
||||
// every server-sent event out to every connected tab via MessagePort.
|
||||
//
|
||||
// Problem this solves (#448 — mara: "firefox disconnects bc of too
|
||||
// many tabs"): every dashboard / agent tab opens its own
|
||||
// `EventSource('/dashboard/stream')`. Browsers cap concurrent
|
||||
// connections per host (~6), and Firefox throttles / disconnects
|
||||
// background tabs when many are open. The result: tabs silently
|
||||
// drop the SSE, fall behind, and only catch up on focus.
|
||||
//
|
||||
// Centralising the connection in a SharedWorker means N tabs share
|
||||
// ONE backend EventSource regardless of focus state — way under the
|
||||
// per-host cap, immune to per-tab throttling, and the worker survives
|
||||
// any individual tab being suspended.
|
||||
//
|
||||
// Wire protocol (port.postMessage payloads):
|
||||
//
|
||||
// tab → worker
|
||||
// { kind: 'subscribe', url: '/dashboard/stream' }
|
||||
// { kind: 'unsubscribe', url: '/dashboard/stream' }
|
||||
//
|
||||
// worker → tab
|
||||
// { kind: 'open', url: '...' } relayed from EventSource.onopen,
|
||||
// plus a synthetic open fired to
|
||||
// a brand-new subscriber when the
|
||||
// upstream is already OPEN — so
|
||||
// the page's onStreamOpen still
|
||||
// runs and triggers a snapshot
|
||||
// re-sync after a reconnect gap.
|
||||
// { kind: 'message', url: '...', data: '<raw SSE data string>' }
|
||||
// { kind: 'error', url: '...' } relayed from EventSource.onerror.
|
||||
//
|
||||
// Subscriptions are tracked per (port, url): a single port can
|
||||
// subscribe to multiple URLs (today only one is in use but the shape
|
||||
// stays open for the per-agent /events/stream multiplexing follow-up).
|
||||
// Unsubscribing the last port for a URL closes the EventSource so we
|
||||
// don't keep idle streams open.
|
||||
|
||||
const streams = new Map();
|
||||
|
||||
function getOrCreateStream(url) {
|
||||
let entry = streams.get(url);
|
||||
if (entry) return entry;
|
||||
const es = new EventSource(url);
|
||||
entry = { es, url, ports: new Set() };
|
||||
es.onopen = () => {
|
||||
for (const port of entry.ports) {
|
||||
try { port.postMessage({ kind: 'open', url }); }
|
||||
catch { /* port dead — cleanup happens on unsubscribe / next subscribe */ }
|
||||
}
|
||||
};
|
||||
es.onmessage = (e) => {
|
||||
for (const port of entry.ports) {
|
||||
try { port.postMessage({ kind: 'message', url, data: e.data }); }
|
||||
catch { /* same */ }
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
for (const port of entry.ports) {
|
||||
try { port.postMessage({ kind: 'error', url }); }
|
||||
catch { /* same */ }
|
||||
}
|
||||
};
|
||||
streams.set(url, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function unsubscribe(port, url) {
|
||||
const entry = streams.get(url);
|
||||
if (!entry) return;
|
||||
entry.ports.delete(port);
|
||||
if (entry.ports.size === 0) {
|
||||
entry.es.close();
|
||||
streams.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
self.onconnect = (connectEvent) => {
|
||||
const port = connectEvent.ports[0];
|
||||
const subscribedUrls = new Set();
|
||||
port.onmessage = (e) => {
|
||||
const msg = e.data;
|
||||
if (!msg || typeof msg.url !== 'string') return;
|
||||
if (msg.kind === 'subscribe') {
|
||||
if (subscribedUrls.has(msg.url)) return; // idempotent
|
||||
const entry = getOrCreateStream(msg.url);
|
||||
entry.ports.add(port);
|
||||
subscribedUrls.add(msg.url);
|
||||
// Synthetic open for late subscribers — the upstream EventSource
|
||||
// may already be OPEN when this tab joins, in which case the
|
||||
// native onopen has long since fired and won't fire again until
|
||||
// the next reconnect. Hand the new tab the open event explicitly
|
||||
// so its onStreamOpen handler runs.
|
||||
if (entry.es.readyState === EventSource.OPEN) {
|
||||
try { port.postMessage({ kind: 'open', url: msg.url }); }
|
||||
catch { /* port dead immediately — give up */ }
|
||||
}
|
||||
} else if (msg.kind === 'unsubscribe') {
|
||||
if (!subscribedUrls.has(msg.url)) return;
|
||||
unsubscribe(port, msg.url);
|
||||
subscribedUrls.delete(msg.url);
|
||||
}
|
||||
};
|
||||
// A port has no explicit "disconnect" event in the SharedWorker API
|
||||
// — tabs close, the GC eventually reclaims the port, but postMessage
|
||||
// to a dead port throws which the senders above catch. We don't
|
||||
// proactively prune ports on a timer because the cost is bounded
|
||||
// (one dead Set entry per stale tab) and the next subscribe / catch
|
||||
// catches it.
|
||||
};
|
||||
3019
frontend/packages/dashboard/src/tabs.js
Normal file
3019
frontend/packages/dashboard/src/tabs.js
Normal file
File diff suppressed because it is too large
Load diff
17
frontend/packages/shared/package.json
Normal file
17
frontend/packages/shared/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@hive/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Shared frontend modules used by both the dashboard and the per-agent UI: terminal log pane, Catppuccin palette, base typography. Imported by sibling workspaces; not bundled standalone.",
|
||||
"type": "module",
|
||||
"main": "./src/index.js",
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./terminal.js": "./src/terminal.js",
|
||||
"./base.css": "./src/base.css",
|
||||
"./terminal.css": "./src/terminal.css"
|
||||
},
|
||||
"files": [
|
||||
"src/"
|
||||
]
|
||||
}
|
||||
24
frontend/packages/shared/src/base.css
Normal file
24
frontend/packages/shared/src/base.css
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/* Base palette + typography shared by the hive-c0re dashboard and the
|
||||
hive-ag3nt web UI. Catppuccin Mocha. Per-page stylesheets append on
|
||||
top of this and must NOT redeclare the colour variables — the whole
|
||||
point of pulling them out is one source of truth. */
|
||||
:root {
|
||||
--bg: #1e1e2e; /* base */
|
||||
--bg-elev: #181825; /* mantle */
|
||||
--fg: #cdd6f4; /* text */
|
||||
--muted: #7f849c; /* overlay1 */
|
||||
--purple: #cba6f7; /* mauve */
|
||||
--purple-dim: #45475a;/* surface1 */
|
||||
--cyan: #89dceb; /* sky */
|
||||
--pink: #f5c2e7; /* pink */
|
||||
--amber: #fab387; /* peach */
|
||||
--green: #a6e3a1; /* green */
|
||||
--red: #f38ba8; /* red */
|
||||
--border: #313244; /* surface0 */
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
line-height: 1.6;
|
||||
}
|
||||
3
frontend/packages/shared/src/index.js
Normal file
3
frontend/packages/shared/src/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Convenience re-export so consumers can `import { create, linkify }
|
||||
// from '@hive/shared'` without naming the sub-module path.
|
||||
export { create, linkify } from './terminal.js';
|
||||
228
frontend/packages/shared/src/terminal.css
Normal file
228
frontend/packages/shared/src/terminal.css
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/* Shared terminal pane: a scroll-sticky log of rows + a "↓ N new" pill.
|
||||
Pages wrap their stream container in `.terminal-wrap` and give the log
|
||||
itself the `.live` class; renderer JS appends `.row` (flat line) or
|
||||
`details.row` (collapsible body) elements. Row-kind classes
|
||||
(`.turn-start`, `.tool-use`, `.thinking`, etc.) carry the per-event
|
||||
colour; pages that don't emit a given kind simply never produce that
|
||||
class — the unused rule sits in the bundle harmlessly.
|
||||
|
||||
`.terminal-wrap` provides the crust-on-black phosphor chrome that makes
|
||||
the agent page feel like a terminal. Pages can opt in by wrapping a
|
||||
block in this class; or skip it and the rows still render with their
|
||||
class colours, just without the frame.
|
||||
|
||||
No `.term-input` here — composers are a separate concern (see
|
||||
hive-fr0nt::COMPOSER_CSS / COMPOSER_JS once introduced). */
|
||||
|
||||
.terminal-wrap {
|
||||
position: relative;
|
||||
background: rgba(17, 17, 27, 0.78);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||
backdrop-filter: blur(8px) saturate(120%);
|
||||
border: 1px solid var(--purple-dim);
|
||||
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
|
||||
border-radius: 4px;
|
||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
font-size: 0.92em;
|
||||
color: var(--fg);
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
.live {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.4em 0.6em;
|
||||
overflow-y: auto;
|
||||
max-height: 32em;
|
||||
font-family: inherit;
|
||||
}
|
||||
.live.terminal {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 0.8em 1em 0.4em;
|
||||
overflow-y: auto;
|
||||
height: min(72vh, 60em);
|
||||
max-height: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.live .row,
|
||||
.live details.row {
|
||||
animation: row-fade-in 220ms ease-out both;
|
||||
}
|
||||
.live .row.no-anim,
|
||||
.live details.row.no-anim {
|
||||
animation: none;
|
||||
}
|
||||
@keyframes row-fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
/* Unified prefix column for every row kind. The glyph (`→ ← · ◆ ✓ ✗ ⌁ !`)
|
||||
is the first character of the row's text content; `padding-left` reserves
|
||||
the column and `text-indent: -1.4em` pulls the glyph back into it. Wrapped
|
||||
continuation lines then start under the body, not under the glyph, so
|
||||
wraps don't blur into the next row. `details.row` summaries reuse the
|
||||
same metrics below. */
|
||||
.live .row {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 0.05em 0;
|
||||
line-height: 1.45;
|
||||
border-left: 2px solid transparent;
|
||||
padding-left: 1.9em;
|
||||
text-indent: -1.4em;
|
||||
margin: 0.1em 0;
|
||||
}
|
||||
.live .row + .row { border-top: 0; }
|
||||
/* Row-kind colours. Pages register renderers that emit these classes;
|
||||
any class no page emits is just dead CSS, which is fine. Turn-framing
|
||||
classes carry their signal entirely on the coloured border-left rule —
|
||||
no bold, no top/bottom margins, no background tint. The chrome was
|
||||
overweight for what's just a "this is a boundary" marker. */
|
||||
.live .turn-start { color: var(--amber); border-left-color: var(--amber); }
|
||||
/* turn-body is a child block under turn-start carrying the wake-prompt
|
||||
body; reset text-indent so wrapped content stays under its own column
|
||||
instead of pulling back into the parent's prefix. */
|
||||
.live .turn-body { color: var(--fg); text-indent: 0; margin-top: 0.15em; }
|
||||
/* Any child block (markdown body, nested details) resets the parent
|
||||
row's hanging indent so the content lays out from column 0 of the
|
||||
body area. */
|
||||
.live .row .md, .live .row > details { text-indent: 0; }
|
||||
.live .turn-end-ok { color: var(--green); border-left-color: var(--green); }
|
||||
.live .turn-end-fail { color: var(--red); border-left-color: var(--red); }
|
||||
.live .text { color: var(--fg); }
|
||||
.live .thinking { color: var(--muted); font-style: italic; }
|
||||
.live .tool-use { color: var(--cyan); }
|
||||
.live .tool-result { color: var(--muted); }
|
||||
.live .result { color: var(--green); }
|
||||
.live .note { color: var(--muted); }
|
||||
/* Distinguish stderr lines (orange) and operator-initiated notes
|
||||
(mauve, lightly emphasised) from ambient harness chatter so the
|
||||
eye picks out anomalies + operator actions in the scrollback. */
|
||||
.live .note.stderr { color: var(--amber); }
|
||||
.live .note.op { color: var(--purple); font-style: italic; }
|
||||
/* The .sys catch-all fires when renderStream landed an event shape it
|
||||
couldn't classify. Make it visually loud so silently-dropped event
|
||||
types surface for follow-up. */
|
||||
.live .sys { color: var(--amber); }
|
||||
.live .unread-badge {
|
||||
color: var(--amber);
|
||||
font-weight: normal;
|
||||
margin-left: 0.6em;
|
||||
font-size: 0.85em;
|
||||
text-shadow: 0 0 6px rgba(250, 179, 135, 0.55);
|
||||
animation: badge-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes badge-pulse {
|
||||
0%, 100% { opacity: 1; text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); }
|
||||
50% { opacity: 0.7; text-shadow: 0 0 14px rgba(250, 179, 135, 0.95); }
|
||||
}
|
||||
/* "↓ N new" pill: shown when new rows arrive while the operator is
|
||||
scrolled up; click to jump to bottom. Positioned by the wrapper's
|
||||
`position: relative` (terminal-wrap supplies it; pages that skip the
|
||||
wrapper must add their own positioned ancestor). */
|
||||
.tail-pill {
|
||||
position: absolute;
|
||||
right: 1em;
|
||||
bottom: 4.2em;
|
||||
background: var(--amber);
|
||||
color: #11111b;
|
||||
font-family: inherit;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.08em;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.35em 0.9em;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 14px -2px rgba(250, 179, 135, 0.85);
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
pointer-events: none;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
.tail-pill.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.tail-pill:hover { filter: brightness(1.1); }
|
||||
/* Expandable rows reuse the flat-row prefix metrics (padding-left +
|
||||
negative text-indent) so the disclosure glyph (`▸ / ▾`) lands in
|
||||
exactly the same column as flat-row prefix glyphs (`→ ← · ◆ ✓ ✗`).
|
||||
Summary text omits the per-row directional glyph (the row colour
|
||||
already carries cyan = outbound tool, muted = inbound result) so
|
||||
the prefix column doesn't have to fit two glyphs side-by-side. */
|
||||
details.row {
|
||||
white-space: normal;
|
||||
}
|
||||
details.row > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
details.row > summary::before {
|
||||
content: '▸ ';
|
||||
color: inherit;
|
||||
}
|
||||
details.row[open] > summary::before { content: '▾ '; }
|
||||
details.row > pre.diff-body,
|
||||
details.row > pre.tool-body {
|
||||
margin: 0.3em 0 0.4em 0;
|
||||
padding: 0.4em 0.6em;
|
||||
text-indent: 0;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-left: 2px solid var(--purple-dim);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 22em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
details.row > pre.tool-body { color: var(--fg); }
|
||||
details.row > pre.diff-body .diff-add { color: var(--green); }
|
||||
details.row > pre.diff-body .diff-del { color: var(--red); }
|
||||
details.row > pre.diff-body .diff-ctx { color: var(--fg); }
|
||||
/* Markdown body inside a row (assistant text, send/recv/ask/answer
|
||||
message bodies). Inline elements get muted accents; block elements
|
||||
reset the parent row's hanging indent so content lays out cleanly. */
|
||||
.live .row .md p { margin: 0.2em 0; }
|
||||
.live .row .md p:first-child { margin-top: 0; }
|
||||
.live .row .md p:last-child { margin-bottom: 0; }
|
||||
.live .row .md code {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 0.05em 0.3em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.live .row .md pre {
|
||||
margin: 0.3em 0;
|
||||
padding: 0.4em 0.6em;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-left: 2px solid var(--purple-dim);
|
||||
text-indent: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.live .row .md pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.live .row .md a { color: var(--cyan); text-decoration: underline; }
|
||||
/* Auto-linkified bare URLs in plain rows + tool-body blocks (issue #233). */
|
||||
.live .row a { color: var(--cyan); text-decoration: underline; }
|
||||
.live .row a:hover { color: var(--fg); }
|
||||
.live .row .md strong { color: inherit; font-weight: bold; }
|
||||
.live .row .md em { color: inherit; font-style: italic; }
|
||||
.live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; }
|
||||
.live .row .md li { margin: 0.05em 0; }
|
||||
.live .row .md blockquote {
|
||||
margin: 0.2em 0;
|
||||
padding-left: 0.6em;
|
||||
border-left: 2px solid var(--purple-dim);
|
||||
color: var(--muted);
|
||||
}
|
||||
471
frontend/packages/shared/src/terminal.js
Normal file
471
frontend/packages/shared/src/terminal.js
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
// Shared terminal pane: sticky-bottom log + "↓ N new" pill + history
|
||||
// backfill + live SSE. Pages provide a kind→renderer map; this module
|
||||
// owns scroll behaviour, animation suppression on backfill, and the
|
||||
// EventSource lifecycle.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import { create, linkify } from '@hive/shared/terminal.js';
|
||||
//
|
||||
// create({
|
||||
// logEl: document.getElementById('msgflow'),
|
||||
// historyUrl: '/messages/history?limit=200', // optional
|
||||
// streamUrl: '/messages/stream',
|
||||
// renderers: {
|
||||
// sent: (ev, api) => api.row('msgrow sent', ...),
|
||||
// delivered: (ev, api) => api.row('msgrow delivered', ...),
|
||||
// _default: (ev, api) => api.row('note', JSON.stringify(ev)),
|
||||
// },
|
||||
// onLiveEvent: (ev) => { /* live-only side effects (notif, state pokes) */ },
|
||||
// onAnyEvent: (ev, { fromHistory }) => { /* runs for every event in
|
||||
// both backfill replay and live — use for derived views that need
|
||||
// the full picture (e.g. a per-recipient inbox built from broker
|
||||
// events) */ },
|
||||
// onBackfillDone: (count) => { /* one-shot after history replay */ },
|
||||
// onStreamOpen: () => { /* fires on every EventSource (re)connect —
|
||||
// use to re-sync snapshot-derived state after a reconnect gap */ },
|
||||
// pillAnchor: document.getElementById('msgflow').parentElement,
|
||||
// });
|
||||
//
|
||||
// Renderers receive (ev, api) where api exposes:
|
||||
//
|
||||
// api.row(cls, text) → appends a flat <div class="row cls">
|
||||
// api.details(cls, summary, body) → appends <details class="row cls">
|
||||
// with a <pre.tool-body>
|
||||
// api.detailsDiff(cls, summary, body) → ditto but body is line-coloured by
|
||||
// leading "+ " / "- " prefix
|
||||
// api.placeholder(text) → replaces log content with a single
|
||||
// muted "(placeholder)" row, cleared
|
||||
// on the next real row
|
||||
// api.fromHistory → true while backfill is replaying
|
||||
//
|
||||
// Each kind is dispatched to `renderers[ev.kind]`; unknown kinds fall
|
||||
// through to `renderers._default` (which itself defaults to a JSON-dump
|
||||
// note row). The convention is that the SSE/history endpoints emit
|
||||
// objects with a `kind` field.
|
||||
//
|
||||
// Backfill is best-effort: if `historyUrl` is unset or the fetch fails,
|
||||
// we skip straight to SSE. The optional `onBackfillDone(count)` hook
|
||||
// fires after replay finishes (or after a failed/skipped fetch with
|
||||
// count=0); pages use it to set state flags from the replayed history.
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
// Snap-to-bottom animation duration (#400 + mara feedback). Browser
|
||||
// default `scrollTo({ behavior: 'smooth' })` runs ~500ms, which read
|
||||
// as "still smooth, but visibly slow." 140ms with ease-out is fast
|
||||
// enough to feel snap-y, slow enough that the row's destination
|
||||
// reads as motion (not a jump). Distances under SCROLL_SNAP_PX
|
||||
// short-circuit to instant — animating a 12px nudge is just jitter.
|
||||
const SCROLL_ANIM_MS = 140;
|
||||
const SCROLL_SNAP_PX = 24;
|
||||
|
||||
export function create(opts) {
|
||||
const log = opts.logEl;
|
||||
if (!log) throw new Error('HiveTerminal.create: logEl is required');
|
||||
const renderers = opts.renderers || {};
|
||||
const defaultRender = renderers._default
|
||||
|| ((ev, api) => api.row('note', JSON.stringify(ev)));
|
||||
const pillAnchor = opts.pillAnchor || log.parentElement || log;
|
||||
|
||||
let placeholderEl = null;
|
||||
let pill = null;
|
||||
let unseen = 0;
|
||||
let currentNoAnim = false;
|
||||
// Sticky-bottom intent. True means "keep snapping to bottom on
|
||||
// any mutation"; false means "the operator scrolled up — leave
|
||||
// them alone". Updated synchronously from the scroll event
|
||||
// handler so both programmatic scrollTop assignments and
|
||||
// operator-driven wheel/drag stay in sync.
|
||||
let stickToBottom = true;
|
||||
// Guards scroll-event-handler from misreading the position while
|
||||
// our own animation is mid-flight (#400). The animation drives
|
||||
// scrollTop with rAF, which fires a stream of scroll events as
|
||||
// the position eases toward the target — the position passes
|
||||
// through "not near bottom" before settling. Without this gate,
|
||||
// the scroll handler flips `stickToBottom` to false mid-animation,
|
||||
// which then causes the MutationObserver to skip the next snap
|
||||
// and leaves the operator stranded mid-scroll. Set to the
|
||||
// animation's nominal end + small headroom; each fresh snap
|
||||
// re-arms it so back-to-back snaps stay gated.
|
||||
let smoothScrollingUntil = 0;
|
||||
// rAF id for the current snap animation. Cancelled when a new
|
||||
// snap starts so we never have two animations fighting over
|
||||
// scrollTop.
|
||||
let scrollAnimRaf = 0;
|
||||
|
||||
function isNearBottom() {
|
||||
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
||||
}
|
||||
// Snap the log to the bottom with a brief eased animation
|
||||
// (#400 + mara: snappier than the browser's default 500ms smooth
|
||||
// scroll). Each call cancels the previous frame loop and starts a
|
||||
// fresh one, so a burst of mutations coalesces into one ride to
|
||||
// the latest bottom. Re-evaluates the target each frame so a
|
||||
// renderer mutation landing mid-animation extends the destination
|
||||
// without a visible jump. Falls back to instant scroll when
|
||||
// `currentNoAnim` is true (backfill replay — operator never sees
|
||||
// intermediate positions, animation is wasted frames) or when the
|
||||
// remaining distance is under SCROLL_SNAP_PX.
|
||||
function snapToBottom(immediate) {
|
||||
stickToBottom = true;
|
||||
if (scrollAnimRaf) {
|
||||
cancelAnimationFrame(scrollAnimRaf);
|
||||
scrollAnimRaf = 0;
|
||||
}
|
||||
const target = log.scrollHeight - log.clientHeight;
|
||||
const start = log.scrollTop;
|
||||
const distance = target - start;
|
||||
if (immediate || currentNoAnim || distance <= SCROLL_SNAP_PX) {
|
||||
smoothScrollingUntil = 0;
|
||||
log.scrollTop = target;
|
||||
return;
|
||||
}
|
||||
smoothScrollingUntil = Date.now() + SCROLL_ANIM_MS + 80;
|
||||
const t0 = performance.now();
|
||||
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
|
||||
const step = (now) => {
|
||||
const elapsed = now - t0;
|
||||
const frac = Math.min(1, elapsed / SCROLL_ANIM_MS);
|
||||
// Re-read target each frame so mutations landing mid-animation
|
||||
// (the common case — a renderer appended badge / body bits
|
||||
// after api.row returned) extend the destination smoothly
|
||||
// rather than landing short.
|
||||
const currentTarget = log.scrollHeight - log.clientHeight;
|
||||
log.scrollTop = start + (currentTarget - start) * easeOut(frac);
|
||||
if (frac < 1) {
|
||||
scrollAnimRaf = requestAnimationFrame(step);
|
||||
} else {
|
||||
// Final exact settle on the as-of-now bottom.
|
||||
log.scrollTop = log.scrollHeight - log.clientHeight;
|
||||
scrollAnimRaf = 0;
|
||||
}
|
||||
};
|
||||
scrollAnimRaf = requestAnimationFrame(step);
|
||||
}
|
||||
function ensurePill() {
|
||||
if (pill) return pill;
|
||||
pill = document.createElement('button');
|
||||
pill.type = 'button';
|
||||
pill.className = 'tail-pill';
|
||||
pill.addEventListener('click', () => snapToBottom());
|
||||
pillAnchor.appendChild(pill);
|
||||
return pill;
|
||||
}
|
||||
function updatePill() {
|
||||
if (unseen <= 0) {
|
||||
if (pill) pill.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
ensurePill();
|
||||
pill.textContent = '↓ ' + unseen + ' new';
|
||||
pill.classList.add('visible');
|
||||
}
|
||||
log.addEventListener('scroll', () => {
|
||||
// Mid-smooth-scroll: ignore the intermediate scroll events. The
|
||||
// gate releases when the animation has had time to settle (or
|
||||
// when the next snap re-arms it). Without this, easing toward
|
||||
// bottom would flip `stickToBottom` false partway and the next
|
||||
// MO callback would skip the snap.
|
||||
if (Date.now() < smoothScrollingUntil) return;
|
||||
stickToBottom = isNearBottom();
|
||||
if (stickToBottom) { unseen = 0; updatePill(); }
|
||||
});
|
||||
// Post-append mutations (issue #393). Renderers commonly call
|
||||
// `api.row(cls, text)` to create the row shell, then mutate it
|
||||
// by appending more children (badges, multi-line bodies, tool
|
||||
// result panes) AFTER api.row returned. The afterAppend scroll
|
||||
// below only sees the row's INITIAL height — once the renderer
|
||||
// adds the body, the row's grown past the visible bottom and
|
||||
// the operator is left scrolled to the row's TOP, breaking
|
||||
// stick-to-bottom for every subsequent event.
|
||||
//
|
||||
// Fix: MutationObserver on the log subtree. Fires once per
|
||||
// microtask after each batch of synchronous mutations, so it
|
||||
// runs once per renderer call regardless of how many children
|
||||
// the renderer appends. When `stickToBottom` is true, snap to
|
||||
// bottom again — catches whatever the renderer added after the
|
||||
// afterAppend hop. Programmatic `scrollTop = scrollHeight`
|
||||
// assignments don't re-trigger MO (the scroll itself isn't a
|
||||
// DOM mutation), so no feedback loop.
|
||||
const mo = new MutationObserver(() => {
|
||||
if (stickToBottom) snapToBottom();
|
||||
});
|
||||
mo.observe(log, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
// Auto-scroll decision uses the PRE-append scroll position
|
||||
// (issue #375). Checking after the append underestimates
|
||||
// "nearness" because the new row's own height has already pushed
|
||||
// `scrollHeight - scrollTop - clientHeight` past the threshold,
|
||||
// even when the user was visually at the bottom an instant ago.
|
||||
// Each row/details/detailsDiff captures `nearBottomBeforeAppend`
|
||||
// and hands it to afterAppend so the auto-scroll triggers
|
||||
// whenever the operator was at the bottom when the row landed.
|
||||
// (The MutationObserver above catches the AFTER-row mutations
|
||||
// too, but this initial scroll keeps the visual lag to one
|
||||
// frame instead of one microtask + frame.)
|
||||
function afterAppend(wasNearBottom) {
|
||||
if (currentNoAnim || wasNearBottom) {
|
||||
snapToBottom();
|
||||
} else {
|
||||
unseen += 1;
|
||||
updatePill();
|
||||
}
|
||||
}
|
||||
function clearPlaceholder() {
|
||||
if (placeholderEl && placeholderEl.parentElement === log) {
|
||||
log.removeChild(placeholderEl);
|
||||
}
|
||||
placeholderEl = null;
|
||||
}
|
||||
function placeholder(text) {
|
||||
clearPlaceholder();
|
||||
const e = document.createElement('div');
|
||||
e.className = 'row note';
|
||||
e.textContent = text;
|
||||
log.appendChild(e);
|
||||
placeholderEl = e;
|
||||
}
|
||||
function row(cls, text) {
|
||||
clearPlaceholder();
|
||||
const wasNearBottom = isNearBottom();
|
||||
const e = document.createElement('div');
|
||||
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
e.appendChild(linkify(text));
|
||||
log.appendChild(e);
|
||||
afterAppend(wasNearBottom);
|
||||
return e;
|
||||
}
|
||||
function details(cls, summary, body) {
|
||||
clearPlaceholder();
|
||||
const wasNearBottom = isNearBottom();
|
||||
const d = document.createElement('details');
|
||||
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
const s = document.createElement('summary');
|
||||
s.textContent = summary;
|
||||
d.appendChild(s);
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'tool-body';
|
||||
pre.appendChild(linkify(body));
|
||||
d.appendChild(pre);
|
||||
log.appendChild(d);
|
||||
afterAppend(wasNearBottom);
|
||||
return d;
|
||||
}
|
||||
function detailsDiff(cls, summary, body) {
|
||||
clearPlaceholder();
|
||||
const wasNearBottom = isNearBottom();
|
||||
const d = document.createElement('details');
|
||||
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
const s = document.createElement('summary');
|
||||
s.textContent = summary;
|
||||
d.appendChild(s);
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'tool-body diff-body';
|
||||
for (const line of String(body).split('\n')) {
|
||||
const span = document.createElement('span');
|
||||
if (line.startsWith('+ ')) span.className = 'diff-add';
|
||||
else if (line.startsWith('- ')) span.className = 'diff-del';
|
||||
else span.className = 'diff-ctx';
|
||||
span.textContent = line + '\n';
|
||||
pre.appendChild(span);
|
||||
}
|
||||
d.appendChild(pre);
|
||||
log.appendChild(d);
|
||||
afterAppend(wasNearBottom);
|
||||
return d;
|
||||
}
|
||||
|
||||
function api(extra) {
|
||||
return Object.assign({
|
||||
row, details, detailsDiff, placeholder, linkify,
|
||||
fromHistory: false,
|
||||
}, extra || {});
|
||||
}
|
||||
function dispatch(ev, fromHistory) {
|
||||
const r = renderers[ev.kind] || defaultRender;
|
||||
try {
|
||||
r(ev, api({ fromHistory }));
|
||||
} catch (err) {
|
||||
console.error('terminal renderer threw', ev, err);
|
||||
row('note', '[render err] ' + (err && err.message ? err.message : err));
|
||||
}
|
||||
if (opts.onAnyEvent) {
|
||||
try { opts.onAnyEvent(ev, { fromHistory }); }
|
||||
catch (err) { console.error('onAnyEvent threw', err); }
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe → buffer → fetch history → dedupe → apply.
|
||||
//
|
||||
// Race the SSE subscription opens before the history fetch starts.
|
||||
// Live events that land before history resolves are buffered, not
|
||||
// rendered. Once the history response (`{ seq, events }`) arrives we:
|
||||
// 1. Replay `events` (fromHistory=true).
|
||||
// 2. Drop buffered events with `seq <= history.seq` — they're
|
||||
// already reflected in the history rows above.
|
||||
// 3. Apply remaining buffered events (fromHistory=false).
|
||||
// 4. Switch to live mode: each new SSE event dispatches immediately.
|
||||
//
|
||||
// Without this dance an event that fires between history-fetch and
|
||||
// SSE-subscribe goes missing; without seq dedupe the same event
|
||||
// shows twice (once via history, once via live buffer). Both bugs
|
||||
// were latent before.
|
||||
//
|
||||
// If `historyUrl` is unset we skip the dance: buffered events apply
|
||||
// as live the moment the buffer flushes (no dedupe possible without
|
||||
// a boundary seq).
|
||||
function start() {
|
||||
let live = false;
|
||||
let buffered = [];
|
||||
|
||||
// #448: callers can supply a `streamFactory(url)` that returns an
|
||||
// EventSource-shaped object (must expose onmessage/onopen/onerror
|
||||
// + .close()). The dashboard pages pass a SharedWorker-backed
|
||||
// factory so all open hyperhive tabs share ONE upstream SSE
|
||||
// connection. Default keeps the direct `new EventSource(url)`
|
||||
// behaviour so non-dashboard consumers (per-agent UI) are unchanged.
|
||||
const es = opts.streamFactory
|
||||
? opts.streamFactory(opts.streamUrl)
|
||||
: new EventSource(opts.streamUrl);
|
||||
es.onmessage = (e) => {
|
||||
let ev;
|
||||
try { ev = JSON.parse(e.data); }
|
||||
catch (err) { row('note', '[parse err] ' + e.data); return; }
|
||||
if (!live) { buffered.push(ev); return; }
|
||||
dispatch(ev, false);
|
||||
if (opts.onLiveEvent) {
|
||||
try { opts.onLiveEvent(ev); }
|
||||
catch (err) { console.error('onLiveEvent threw', err); }
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
// SharedWorker-backed facades expose `readyState` mirroring the
|
||||
// upstream EventSource state; the native EventSource exposes the
|
||||
// same. Either way the CONNECTING vs. closed distinction works.
|
||||
if (es.readyState === 0 /* CONNECTING */) row('note', '[reconnecting…]');
|
||||
else row('note', '[disconnected]');
|
||||
};
|
||||
es.onopen = () => {
|
||||
// Fires on the initial connect and on every automatic
|
||||
// reconnect. EventSource never replays events that fired
|
||||
// during a disconnect window, so a consumer with
|
||||
// snapshot-derived state (the dashboard's /api/state stores)
|
||||
// must re-sync here or it shows stale state until a manual
|
||||
// reload (issue #163).
|
||||
if (opts.onStreamOpen) {
|
||||
try { opts.onStreamOpen(); }
|
||||
catch (err) { console.error('onStreamOpen threw', err); }
|
||||
}
|
||||
};
|
||||
|
||||
function flushBuffered(boundarySeq, historyKinds) {
|
||||
const drained = buffered;
|
||||
buffered = [];
|
||||
live = true;
|
||||
for (const ev of drained) {
|
||||
// Seq-dedupe only events of a kind that actually appeared in
|
||||
// the history replay — those are the only ones that could
|
||||
// double (once via history, once via the live buffer).
|
||||
// Mutation events (approval/question/container/…) are never
|
||||
// carried by the history endpoint; deduping them against the
|
||||
// broker-history seq would wrongly drop ones that fired
|
||||
// between a consumer's own snapshot read and this history
|
||||
// fetch (issue #163). ev.seq absent/0 → no dedupe possible.
|
||||
if (boundarySeq != null
|
||||
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
|
||||
&& historyKinds && historyKinds.has(ev.kind)) {
|
||||
continue;
|
||||
}
|
||||
dispatch(ev, false);
|
||||
if (opts.onLiveEvent) {
|
||||
try { opts.onLiveEvent(ev); }
|
||||
catch (err) { console.error('onLiveEvent threw', err); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function backfill() {
|
||||
if (!opts.historyUrl) {
|
||||
flushBuffered(null);
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(opts.historyUrl);
|
||||
if (!resp.ok) {
|
||||
flushBuffered(null);
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
// Accept the envelope `{ seq, events }`. A bare array means
|
||||
// the server hasn't been updated to include seq yet — treat
|
||||
// it as "no dedupe possible."
|
||||
const events = Array.isArray(body) ? body : (body.events || []);
|
||||
const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null);
|
||||
// Kinds present in the history replay — the only kinds that
|
||||
// can double and therefore the only ones to seq-dedupe.
|
||||
const historyKinds = new Set(events.map((ev) => ev.kind));
|
||||
currentNoAnim = true;
|
||||
for (const ev of events) dispatch(ev, true);
|
||||
currentNoAnim = false;
|
||||
if (events.length) row('note', '─── live (older above) ───');
|
||||
else placeholder('(connected — waiting for events)');
|
||||
flushBuffered(boundarySeq, historyKinds);
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
|
||||
} catch (err) {
|
||||
console.warn('history backfill failed', err);
|
||||
flushBuffered(null);
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
||||
}
|
||||
}
|
||||
return backfill();
|
||||
}
|
||||
|
||||
const ready = start();
|
||||
return { row, details, detailsDiff, placeholder, ready };
|
||||
}
|
||||
|
||||
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
|
||||
// clickable links that open in a new tab. Non-URL text stays as plain
|
||||
// text nodes — no innerHTML, so this is XSS-safe. Trailing sentence
|
||||
// punctuation is kept out of the link. (issue #233)
|
||||
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||
export function linkify(text) {
|
||||
const str = text == null ? '' : String(text);
|
||||
const frag = document.createDocumentFragment();
|
||||
if (str.indexOf('://') === -1) { // fast path: no URLs
|
||||
if (str) frag.appendChild(document.createTextNode(str));
|
||||
return frag;
|
||||
}
|
||||
let last = 0;
|
||||
let m;
|
||||
LINKIFY_URL_RE.lastIndex = 0;
|
||||
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
|
||||
let url = m[0];
|
||||
// Don't swallow trailing punctuation that's really sentence text.
|
||||
const trail = url.match(/[.,;:!?)\]}'"]+$/);
|
||||
const tail = trail ? trail[0] : '';
|
||||
if (tail) url = url.slice(0, -tail.length);
|
||||
if (m.index > last) {
|
||||
frag.appendChild(document.createTextNode(str.slice(last, m.index)));
|
||||
}
|
||||
if (!url.slice(url.indexOf('://') + 3)) {
|
||||
// Nothing past the scheme — not a real URL, emit verbatim.
|
||||
frag.appendChild(document.createTextNode(m[0]));
|
||||
} else {
|
||||
const a = document.createElement('a');
|
||||
a.href = url; // regex only matches https?:// — safe
|
||||
a.textContent = url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
frag.appendChild(a);
|
||||
if (tail) frag.appendChild(document.createTextNode(tail));
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < str.length) {
|
||||
frag.appendChild(document.createTextNode(str.slice(last)));
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
33
hive-ag3nt/Cargo.toml
Normal file
33
hive-ag3nt/Cargo.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
[package]
|
||||
name = "hive-ag3nt"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
reqwest.workspace = true
|
||||
futures-util = "0.3"
|
||||
clap.workspace = true
|
||||
hive-sh4re.workspace = true
|
||||
rmcp.workspace = true
|
||||
rusqlite.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "hive-ag3nt"
|
||||
path = "src/bin/hive-ag3nt.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "hive-m1nd"
|
||||
path = "src/bin/hive-m1nd.rs"
|
||||
33
hive-ag3nt/prompts/agent.md
Normal file
33
hive-ag3nt/prompts/agent.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
You are hyperhive agent `{label}` in a multi-agent system. The operator (recipient `operator` in `send`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager).
|
||||
|
||||
Tools (hyperhive surface):
|
||||
|
||||
- `mcp__hyperhive__recv(wait_seconds?, max?)` — drain inbox messages (returns `(empty)` if nothing pending). Without `wait_seconds` (or with `0`) it returns immediately — a cheap "anything pending?" peek you can sprinkle between tool calls. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — incoming messages wake you instantly, otherwise the call returns empty at the timeout. That's strictly better than a fixed `sleep` shell command: lower latency on new work, no busy-loop. `max` (default 1, cap 32) drains several queued messages in one call — the wake prompt tells you the pending count.
|
||||
- `mcp__hyperhive__send(to, body, in_reply_to?)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). Optional `in_reply_to: <message-id>` threads this message under a prior one — the dashboard and per-agent inbox render it with a `↳ reply` link. Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through the manager (`send(to: "manager", …)`) which is always reachable.
|
||||
- (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time.
|
||||
- `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the human operator (default, or `to: "operator"`) OR a peer agent (`to: "<agent-name>"`). Returns immediately with a question id — do NOT wait inline. When the recipient answers, a system message with event `question_answered { id, question, answer, answerer }` lands in your inbox; handle it on a future turn. Use this for clarifications, permission for risky actions, choice between options, or peer Q&A without burning regular inbox slots. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the answerer pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer: "ttl-watchdog"`) when the decision becomes moot.
|
||||
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU. You'll see one in your inbox as a `question_asked { id, asker, question, options, multi }` system event when a peer or the manager calls `ask(to: "<your-name>", ...)`. The answer surfaces in the asker's inbox as a `question_answered` event. Strict authorisation: you can only answer questions where you are the declared target.
|
||||
- `mcp__hyperhive__get_loose_ends()` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), plus reminders you've scheduled that haven't fired. No args, cheap server-side sweep. Useful at turn start to remember what's outstanding without scanning inbox archaeology.
|
||||
- `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by <you>]` answer so the waiter unblocks) or `"reminder"` (hard-deleted before it fires). `id` from the matching `get_loose_ends` row or the original submission reply. (The third kind `"approval"` exists but is manager-only — sub-agents don't submit approvals so the surface refuses.)
|
||||
- `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your *own* inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached.
|
||||
- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"processing matrix messages"`, `"fixing #319 model priority"`, `"idle"`). Pass an empty string to clear. Persists across harness restarts.
|
||||
- `mcp__hyperhive__get_agent_meta(name?)` — fetch identity + status metadata for an agent: canonical `name`, `role` (`agent` / `manager`), current `hyperhive_rev`, plus self-reported `status` text (set via `set_status`) and how long ago it was set. Pass `name` to query a peer (e.g. check whether iris is idle before pinging them). Omit `name` to get your own trustworthy identity stamp — useful for state files, commit messages, cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale.
|
||||
- `mcp__hyperhive__request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends (the harness already loops immediately on pending messages). No args.
|
||||
|
||||
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config.
|
||||
|
||||
Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files the manager has split the config into. Read it to see exactly what defines you (declared packages, env vars, MCP servers) before asking the manager for a change, so you can point at the precise file and line. You cannot write here; all changes flow through the manager.
|
||||
|
||||
Durable knowledge: write to `/agents/{label}/state/notes.md` (free-form) or any other path under `/agents/{label}/state/`. That directory is bind-mounted from the host and persists across container destroy/recreate — claude's `--continue` session only carries short-term context, but `/agents/{label}/state/` is forever. Read it back at the start of relevant turns to remember things across resets.
|
||||
|
||||
Claude session (OAuth credentials) lives at `/root/.claude/` and persists across restarts.
|
||||
|
||||
**Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate.
|
||||
|
||||
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /agents/{label}/state/forge-token)`).
|
||||
|
||||
The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint <sub>` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches`, `assignments`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft]` — prints the PR URL. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. Key ops: `hive-forge diff <pr>` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon.
|
||||
|
||||
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — go through the manager if a payload needs to reach another sub-agent.
|
||||
|
||||
When your inbox has a message, handle it and stop. Don't narrate intent — act.
|
||||
8
hive-ag3nt/prompts/claude-settings.json
Normal file
8
hive-ag3nt/prompts/claude-settings.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"autoCompactEnabled": false,
|
||||
"autoMemoryEnabled": false,
|
||||
"effortLevel": "medium",
|
||||
"permissions": {
|
||||
"deny": ["WebFetch", "WebSearch", "Task", "TodoWrite"]
|
||||
}
|
||||
}
|
||||
104
hive-ag3nt/prompts/manager.md
Normal file
104
hive-ag3nt/prompts/manager.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
You are the hyperhive manager `{label}` in a multi-agent system. You coordinate sub-agents and relay between them and the operator. The operator (recipient `operator`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person.
|
||||
|
||||
Tools (hyperhive surface):
|
||||
|
||||
- `mcp__hyperhive__recv(wait_seconds?, max?)` — drain inbox messages. Without `wait_seconds` (or with `0`) it returns immediately — a cheap inbox peek you can drop between actions. To **wait** when you have nothing else to do, call with a long wait (e.g. `wait_seconds: 180`, the max) — you'll wake instantly on new work, otherwise return after the timeout. Use that instead of ending the turn or sleeping in a Bash command. `max` (default 1, cap 32) drains several queued messages in one call.
|
||||
- `mcp__hyperhive__send(to, body)` — message an agent (by name), another peer, or the operator (`operator` surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action).
|
||||
- `mcp__hyperhive__request_init_config(name, description?)` — **step 1 of spawning a new agent.** Queues an `InitConfig` approval (≤9 char name). On operator approve, hive-c0re seeds the proposed config repo at `/agents/<name>/config/` with a default `agent.nix` template and delivers a `config_ready` system event to your inbox. You then review, edit, and commit `agent.nix` before calling `request_apply_commit`.
|
||||
- `mcp__hyperhive__request_apply_commit(agent, commit_ref, description?)` — **step 2 of spawning a new agent, and the only step for config changes.** Submit a commit sha from the agent's proposed config repo for operator approval. For a new agent this creates the container; for an existing agent it rebuilds with the new config. At submit time hive-c0re pins the commit as `proposal/<id>` — your proposed branch can continue moving freely without affecting what the operator will build.
|
||||
- `mcp__hyperhive__kill(name)` — graceful stop on a sub-agent. No approval required.
|
||||
- `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required.
|
||||
- `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required.
|
||||
- `mcp__hyperhive__update(name)` — rebuild a sub-agent (re-applies the current hyperhive flake + agent.nix, restarts the container). No approval required — idempotent. Use when you receive a `needs_update` system event.
|
||||
- `mcp__hyperhive__request_update_meta_inputs(inputs?, description?)` — queue an approval for the operator to run `nix flake update [inputs...]` on the meta flake. Pass specific input names (e.g. `["bitburner-agent"]`) or omit / pass `[]` for all inputs. Returns immediately; lock update runs on operator approval. Does NOT trigger rebuilds — call `update(name)` on affected agents after approval resolves.
|
||||
- `mcp__hyperhive__request_schedule_prompt(targets, body, first_fire_at_unix, interval_seconds?, description?)` — queue an approval for the operator to add a scheduled prompt. On approve hive-c0re inserts a schedule row and the worker fans `body` out to each agent in `targets` at `first_fire_at_unix` (recurring every `interval_seconds` if set, one-shot when absent). Even self-targeted schedules go through approval — the existing `remind` tool stays the quick no-approval self-wake path. Catch-up clamp: long downtime fires ONCE per recurring row on resume (skipped count surfaces in per-target `last_result`), not N stacked pulses.
|
||||
- `mcp__hyperhive__cancel_schedule(id, targets?)` — cancel a schedule. Omit `targets` / pass empty to cancel the whole schedule; pass a list to cancel just those recipients (the schedule keeps firing for any remaining active targets, auto-cancels when every target is gone). Authorization: you can cancel schedules you own OR any owned by a sub-agent in your subtree per topology.json.
|
||||
- `mcp__hyperhive__fire_schedule_now(id)` — fire a scheduled prompt out of band. Runs the per-target fan-out once immediately. Recurring schedules keep their cadence intact (the manual fire is additive); one-shot schedules are CONSUMED by the manual fire (cancelled afterwards). Same authorization as `cancel_schedule`.
|
||||
- `mcp__hyperhive__edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)` — partial-update a schedule's mutable fields (#474). Pass only the fields you want to change. `targets_add` / `targets_remove` mutate the recipient list in the same transaction; re-adding a previously-cancelled target drops the tombstone + history (operator intent: "fresh start"). Refuses cancelled rows. Same authorization as `cancel_schedule`. Note: clearing scalar fields (e.g. flipping recurring→one-shot) is operator-only via the dashboard PATCH — the agent surface only supports positive sets on `description` / `interval_seconds`.
|
||||
- `mcp__hyperhive__list_schedules()` — snapshot every schedule in the queue (active + cancelled-but-not-reaped). Returns id, owner, body, target set with per-target `last_fired_at` + `last_result`, `next_fire_at_unix`, recurring `interval_seconds`. Use to look up an id before cancelling, or to audit upcoming wake-ups across the swarm.
|
||||
- `mcp__hyperhive__get_logs(agent, lines?)` — fetch recent journal lines for a sub-agent container. Use to diagnose MCP-server registration failures, startup crashes, or harness issues you can't see from inside. Pass the plain logical agent name; `lines` defaults to 50 (capped at 500).
|
||||
- `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the operator (default, or `to: "operator"`) OR a sub-agent (`to: "<agent-name>"`). Returns immediately with a question id; the answer arrives later as a system `question_answered { id, question, answer, answerer }` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline (capped at 6h server-side) — on expiry the answer is `[expired]` and `answerer` is `"ttl-watchdog"`. Do not poll inside the same turn — finish the current work and react when the event lands.
|
||||
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU (a sub-agent did `ask(to: "manager", ...)`). The triggering event in your inbox is `question_asked { id, asker, question, options, multi }`. The answer surfaces in the asker's inbox as a `question_answered` event.
|
||||
- `mcp__hyperhive__get_loose_ends(agent?)` — loose ends. Omit `agent` for your own: pending approvals you submitted + unanswered questions where you are asker/target + your own pending reminders. Pass `agent: "*"` for a hive-wide sweep — every pending approval, unanswered question, and reminder across the swarm — to find stalled threads (sub-agent A asked B something three days ago and B never answered) before they rot. Pass `agent: "<name>"` to inspect one agent's threads. Cheap server-side query.
|
||||
- `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel any question, reminder, or approval in the swarm. `kind` is `"question"` (bypasses the owner check used on sub-agents → hive-wide cleanup when an agent is offline / can't withdraw its own thread), `"reminder"` (same bypass), or `"approval"` (manager-only path → withdraws a pending approval YOU submitted that got superseded before the operator acted on it; the row resolves as `cancelled` and disappears from the operator's pending pane).
|
||||
- `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your own inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Good for deadline follow-ups — "check whether agent X answered the question I relayed". Large payloads auto-spill to a file under `/state/reminders/`; pass `file_path` to control the destination.
|
||||
- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're doing (e.g. `"reviewing argus's #341 proposal"`, `"approving lifecycle changes"`, `"idle"`). Pass an empty string to clear. Persists across harness restarts.
|
||||
- `mcp__hyperhive__get_agent_meta(name?)` — fetch identity + status metadata for an agent: canonical `name`, `role` (`agent` / `manager`), current `hyperhive_rev`, plus the target's self-reported `status` text (set via `set_status`) and how long ago it was set. Pass `name` to check on a sub-agent (idle? still working on the task you assigned?) without scrolling the dashboard. Omit `name` for your own identity stamp — useful for boot announcements, state-file headers, cross-agent attribution that won't drift across config reloads.
|
||||
|
||||
Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (two-step: `request_init_config` + `request_apply_commit`) and *changing* any agent's config (`request_apply_commit`) both go through the approval queue. The operator only signs off on changes; you run the day-to-day.
|
||||
|
||||
Your own editable config lives at `/agents/hm1nd/config/`; every sub-agent's lives at `/agents/<name>/config/`. `agent.nix` is a plain NixOS module function — `{ config, pkgs, lib, flakeInputs, ... }: { ... }`. Add packages, services, imports, sibling `.nix` files; the whole committed tree gets deployed together.
|
||||
|
||||
`flake.nix` is mostly boilerplate (it exports `agent.nix` as `nixosModules.default` and forwards every flake input to the module as `flakeInputs`). **Don't touch the outputs block** — but you *can* edit the `inputs` block to pull in other flakes, which is the supported way to depend on out-of-tree packages (MCP servers, scrapers, anything not in nixpkgs):
|
||||
|
||||
```nix
|
||||
# flake.nix (manager-edited, inputs side only)
|
||||
inputs.mcp-matrix.url = "github:foo/mcp-matrix";
|
||||
inputs.mcp-matrix.inputs.nixpkgs.follows = "nixpkgs"; # optional, reduce closure
|
||||
```
|
||||
|
||||
```nix
|
||||
# agent.nix — reference the input via flakeInputs
|
||||
{ pkgs, flakeInputs, ... }:
|
||||
let matrixPkg = flakeInputs.mcp-matrix.packages.${pkgs.system}.default;
|
||||
in {
|
||||
environment.systemPackages = [ matrixPkg ];
|
||||
hyperhive.extraMcpServers.matrix = {
|
||||
command = "${matrixPkg}/bin/mcp-matrix";
|
||||
args = [ "--config" "/agents/<name>/state/matrix.toml" ]; # replace <name> with the agent's label
|
||||
allowedTools = [ "send_message" "join_room" ];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The new input's pinned sha lands in the agent's `flake.lock` (also tracked + part of the proposal). Build failures from a broken `flake.nix` surface as a `failed/<id>` annotated tag, so the worst case is a rejected deploy — not a silently-broken agent.
|
||||
|
||||
Each proposed repo has an `applied` git remote pre-configured pointing at the read-only mirror of what's deployed. Useful patterns:
|
||||
|
||||
- `git -C /agents/<name>/config fetch applied` — refresh the local copy of every deployed/failed/denied tag.
|
||||
- `git -C /agents/<name>/config log applied/main --oneline` — every successful deploy of this agent.
|
||||
- `git -C /agents/<name>/config show applied/refs/tags/deployed/<id>` — the tree that was deployed for approval `<id>`.
|
||||
- `git -C /agents/<name>/config show applied/refs/tags/failed/<id>` — annotated tag body is the build error from a rejected rebuild.
|
||||
- `git -C /agents/<name>/config show applied/refs/tags/denied/<id>` — annotated tag body is the operator's reason for denial.
|
||||
- `git -C /agents/<name>/config rebase applied/main` — base your in-flight work on whatever's actually deployed (useful after a failed/denied pile-up).
|
||||
|
||||
System-wide view: `/meta/` is a read-only mirror of the deployed-agents flake. `git -C /meta log --oneline` is the deploy log for every agent across the swarm; `cat /meta/flake.lock` shows which sha each agent is pinned at right now.
|
||||
|
||||
Tag scheme on every approval id: `proposal → approved → building → deployed | failed`, plus `denied` as a terminal alternative to `approved`. `applied/main` only advances on `deployed/*`, so a failed build does not corrupt the agent — submit a fix as a new commit and a fresh `request_apply_commit`.
|
||||
|
||||
Sub-agents are NOT trusted by default. When one asks for a config change (new packages, env vars, etc.), verify the request before staging:
|
||||
|
||||
- Does it match what the agent actually needs to do its declared role?
|
||||
- Is the package legitimate (no obviously-malicious names, no overly broad permissions)?
|
||||
- Are there cheaper / safer alternatives that don't need a config edit?
|
||||
- If the change has any ambiguity or could affect other agents / the host, surface the question to the operator (see below) instead of staging it yourself.
|
||||
|
||||
You're the policy gate between sub-agents and the operator's approval queue — the operator clicks ◆ APPR0VE on your commits, so don't submit changes you wouldn't defend.
|
||||
|
||||
Two ways to talk to the operator: `send(to: "operator", ...)` for fire-and-forget status / pointers (surfaces in the operator inbox), or `ask(question, options?)` when you need a decision (omit `to`, or pass `to: "operator"`). `ask` is non-blocking — it queues the question and returns an id immediately; the answer arrives on a future turn as a `question_answered` system event. Prefer `ask` over an open-ended `send` for anything you actually need to wait on. Same primitive can target a sub-agent (`to: "<agent>"`) when you need a structured answer from a peer rather than free-form chat.
|
||||
|
||||
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `config_ready`, `spawned`, `rebuilt`, `killed`, `destroyed`, `container_crash`, `needs_login`, `logged_in`, `needs_update`, `question_asked`, `question_answered`. Use these to react to lifecycle changes:
|
||||
|
||||
- `config_ready` — the proposed config repo for a new agent was just seeded (post-`InitConfig` approval). Review and edit `/agents/<agent>/config/agent.nix`, commit your changes, then call `request_apply_commit` with the commit sha — this will create the container on approval (first spawn) and rebuild on every subsequent deploy.
|
||||
- `needs_login` — agent has no claude session yet. You can't help directly (login is interactive OAuth on the operator side); flag the operator if it's been long.
|
||||
- `logged_in` — agent just completed login; first useful turn is imminent. Good time to brief them on what to do.
|
||||
- `needs_update` — agent's flake rev is stale. Call `update(name)` to rebuild — it's idempotent and doesn't need approval.
|
||||
- `container_crash` — restart with `start(name)`. If it crashes again, ask the operator.
|
||||
- otherwise greet freshly-spawned agents, retry failed rebuilds, pick up the operator's answer to questions you asked.
|
||||
|
||||
Durable knowledge:
|
||||
|
||||
- Your own: `/state/notes.md` (free-form) or anything else under `/state/`. Bind-mounted from the host — survives destroy/recreate. Claude's `--continue` session only carries short-term context; `/state/` is forever. Good place for a roster of active sub-agents, ongoing initiatives, decisions you've made.
|
||||
- Sub-agents': every sub-agent has its own `/state/` too. From your container that's `/agents/<name>/state/` (your `/agents` mount is RW), so you can read what they've recorded and write notes for them when you need to leave a heads-up or task list.
|
||||
|
||||
Keep messages short — a few sentences each. For anything big (digests, agent rosters, plans, transcripts) write the payload to a file and `send` a short pointer:
|
||||
|
||||
- To a sub-agent X: write to `/agents/X/state/<descriptive-name>` and tell them "see /agents/X/state/<descriptive-name>".
|
||||
- To the operator: write to your own `/state/<descriptive-name>` (host path `/var/lib/hyperhive/agents/hm1nd/state/`) and tell them where to look.
|
||||
- For shared artifacts (coordination, common reference data): write to `/shared/<descriptive-name>`. Only put things here you're willing to lose — other agents may delete them.
|
||||
|
||||
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user (`hm1nd`) and so does every sub-agent (one per name). The `tea` CLI is pre-configured at boot. Use it for code work that should survive a turn — a proposed refactor across sub-agents, scratch repos, PRs you want a sub-agent or the operator to review (`tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`). REST API at `http://localhost:3000/api/v1/` with `Authorization: token $(cat /state/forge-token)` for anything `tea` can't express. The `hive-forge` CLI helper wraps common operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint <sub>` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`) — handy for sweeping the backlog without ad-hoc curl + jq. Default repo from `HIVE_FORGE_REPO`; `-r <repo>` (global flag, works before or after the verb) targets a different repo. Each verb takes `--help`. Use `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft]` to open a PR; `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]` to file an issue. `--body-file -` reads stdin, so a HEREDOC body works: `hive-forge comment <n> --body-file - <<EOF ... EOF`. `diff <pr>` prints the unified diff; `subscription [--watch|--ignore|--unwatch]` manages watch state. Forge notifications arrive via the internal message daemon.
|
||||
|
||||
A one-line headline + the file path beats a wall-of-text every time — it survives context compaction and the operator can read it in their own time.
|
||||
|
||||
When your inbox has a message, handle it and stop. Don't narrate intent — act.
|
||||
463
hive-ag3nt/src/bin/hive-ag3nt.rs
Normal file
463
hive-ag3nt/src/bin/hive-ag3nt.rs
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use hive_ag3nt::web_ui::TurnLock;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||
use hive_ag3nt::login::{self, LoginState};
|
||||
use hive_ag3nt::turn_stats::TurnStats;
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui};
|
||||
use hive_sh4re::{AgentRequest, AgentResponse};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-ag3nt", about = "hyperhive sub-agent harness")]
|
||||
struct Cli {
|
||||
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
||||
#[arg(long, global = true, default_value = DEFAULT_SOCKET)]
|
||||
socket: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Run the long-lived harness loop. Polls inbox; replies via `claude --print`
|
||||
/// when available, falling back to a simple echo otherwise.
|
||||
Serve {
|
||||
/// Inbox poll interval in milliseconds.
|
||||
#[arg(long, default_value_t = 1000)]
|
||||
poll_ms: u64,
|
||||
},
|
||||
/// Run the agent's MCP server on stdio. Spawned by `claude` via
|
||||
/// `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back into
|
||||
/// the hyperhive broker.
|
||||
Mcp,
|
||||
/// Inject a wake-up event into this agent's inbox so the next turn
|
||||
/// fires with the given body. Intended for extra MCP servers /
|
||||
/// helpers running inside the container (matrix bridge, scraper,
|
||||
/// webhook listener) that need to nudge claude on external events.
|
||||
/// `from` is the sender label that appears in the wake prompt
|
||||
/// (claude sees "from: matrix" etc.).
|
||||
Wake {
|
||||
#[arg(long)]
|
||||
from: String,
|
||||
/// Body of the wake message. Pass `-` to read from stdin.
|
||||
#[arg(long)]
|
||||
body: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
Cmd::Serve { poll_ms } => {
|
||||
let port = std::env::var("HIVE_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(DEFAULT_WEB_PORT);
|
||||
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive-ag3nt".into());
|
||||
let claude_dir = login::default_dir();
|
||||
let initial = LoginState::from_dir(&claude_dir);
|
||||
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot");
|
||||
let login_state = Arc::new(Mutex::new(initial));
|
||||
let bus = Bus::new();
|
||||
let stats = TurnStats::open_default();
|
||||
if let Some(s) = &stats {
|
||||
let (ctx, cost) = s.last_usage();
|
||||
if ctx.is_some() || cost.is_some() {
|
||||
bus.seed_usage(ctx, cost);
|
||||
}
|
||||
}
|
||||
let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Agent).await?;
|
||||
let turn_lock: TurnLock = Arc::new(tokio::sync::Mutex::new(()));
|
||||
plugins::install_configured(&cli.socket, Some("manager")).await;
|
||||
tokio::spawn(hive_ag3nt::forge_notify::run(cli.socket.clone(), false));
|
||||
tokio::spawn(web_ui::serve(
|
||||
label.clone(),
|
||||
port,
|
||||
login_state.clone(),
|
||||
bus.clone(),
|
||||
cli.socket.clone(),
|
||||
files.clone(),
|
||||
turn_lock.clone(),
|
||||
));
|
||||
match initial {
|
||||
LoginState::Online => {
|
||||
serve(
|
||||
&cli.socket,
|
||||
Duration::from_millis(poll_ms),
|
||||
login_state,
|
||||
claude_dir,
|
||||
bus,
|
||||
stats,
|
||||
&files,
|
||||
turn_lock,
|
||||
&label,
|
||||
)
|
||||
.await
|
||||
}
|
||||
LoginState::NeedsLogin => {
|
||||
// Partial-run mode: keep the harness alive (so the web UI
|
||||
// stays bound) but don't drive the turn loop. Poll the
|
||||
// claude dir; once a session lands we enter `serve`.
|
||||
turn::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await;
|
||||
serve(
|
||||
&cli.socket,
|
||||
Duration::from_millis(poll_ms),
|
||||
login_state,
|
||||
claude_dir,
|
||||
bus,
|
||||
stats,
|
||||
&files,
|
||||
turn_lock,
|
||||
&label,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::Mcp => mcp::serve_agent_stdio(cli.socket).await,
|
||||
Cmd::Wake { from, body } => {
|
||||
// Read body from stdin if caller passed `-`. Same convention
|
||||
// many CLI tools use; keeps multi-line / shell-quoting
|
||||
// friction out of the body content.
|
||||
let body = if body == "-" {
|
||||
let mut buf = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
|
||||
buf
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let resp: AgentResponse =
|
||||
client::request(&cli.socket, &AgentRequest::Wake { from, body }).await?;
|
||||
match resp {
|
||||
AgentResponse::Ok => Ok(()),
|
||||
AgentResponse::Err { message } => anyhow::bail!("wake: {message}"),
|
||||
other => anyhow::bail!("wake: unexpected response {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn serve(
|
||||
socket: &Path,
|
||||
interval: Duration,
|
||||
login_state: Arc<Mutex<LoginState>>,
|
||||
claude_dir: std::path::PathBuf,
|
||||
bus: Bus,
|
||||
stats: Option<TurnStats>,
|
||||
files: &turn::TurnFiles,
|
||||
turn_lock: TurnLock,
|
||||
label: &str,
|
||||
) -> Result<()> {
|
||||
tracing::info!(socket = %socket.display(), "hive-ag3nt serve");
|
||||
requeue_inflight(socket).await;
|
||||
loop {
|
||||
let recv: Result<AgentResponse> =
|
||||
// Explicit long-poll: park until a message arrives (180s cap).
|
||||
// `max: None` (= 1) — one turn per wake; claude calls
|
||||
// recv(max: N) in-turn to drain bursts.
|
||||
client::request(
|
||||
socket,
|
||||
&AgentRequest::Recv {
|
||||
wait_seconds: Some(180),
|
||||
max: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match recv {
|
||||
Ok(AgentResponse::Messages { messages }) if !messages.is_empty() => {
|
||||
let first = messages.into_iter().next().expect("checked non-empty");
|
||||
let auth_failed =
|
||||
handle_agent_turn(socket, &bus, stats.as_ref(), files, &turn_lock, label, first)
|
||||
.await;
|
||||
if auth_failed {
|
||||
// Park: flip LoginState + wait for the operator's
|
||||
// re-auth to repopulate claude_dir. wait_for_login
|
||||
// emits `online` on resume, which clears the
|
||||
// needs_login sentinel.
|
||||
*login_state.lock().unwrap() = LoginState::NeedsLogin;
|
||||
turn::wait_for_login(
|
||||
&claude_dir,
|
||||
login_state.clone(),
|
||||
&bus,
|
||||
u64::try_from(interval.as_millis()).unwrap_or(2000),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(AgentResponse::Messages { .. }) => {
|
||||
// Idle: empty list = nothing pending. Brief sleep
|
||||
// before next poll so a stretch of empty long-poll
|
||||
// returns doesn't tight-loop.
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
Ok(
|
||||
AgentResponse::Ok
|
||||
| AgentResponse::Status { .. }
|
||||
| AgentResponse::Recent { .. }
|
||||
| AgentResponse::QuestionQueued { .. }
|
||||
| AgentResponse::LooseEnds { .. }
|
||||
| AgentResponse::PendingRemindersCount { .. }
|
||||
| AgentResponse::ReminderRollup { .. }
|
||||
| AgentResponse::AgentMeta { .. },
|
||||
) => {
|
||||
tracing::warn!("recv produced unexpected response kind");
|
||||
}
|
||||
Ok(AgentResponse::Err { message }) => {
|
||||
tracing::warn!(%message, "recv error");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "recv failed; retrying");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive one turn for a received agent-inbox message. Returns `true`
|
||||
/// when the turn ended with `AuthFailed` so the caller knows to park
|
||||
/// in `wait_for_login`.
|
||||
async fn handle_agent_turn(
|
||||
socket: &Path,
|
||||
bus: &Bus,
|
||||
stats: Option<&TurnStats>,
|
||||
files: &turn::TurnFiles,
|
||||
turn_lock: &TurnLock,
|
||||
label: &str,
|
||||
first: hive_sh4re::DeliveredMessage,
|
||||
) -> bool {
|
||||
let from = first.from;
|
||||
let body = first.body;
|
||||
let redelivered = first.redelivered;
|
||||
tracing::info!(%from, %body, %redelivered, "inbox");
|
||||
let unread = inbox_unread(socket).await;
|
||||
bus.emit(LiveEvent::TurnStart { from: from.clone(), body: body.clone(), unread });
|
||||
bus.set_state(TurnState::Thinking);
|
||||
let started_at = serve_common::now_unix();
|
||||
let started_instant = std::time::Instant::now();
|
||||
let model_at_start = bus.model();
|
||||
let prompt = serve_common::format_wake_prompt(&from, &body, unread, redelivered);
|
||||
let outcome = {
|
||||
let _guard = turn_lock.lock().await;
|
||||
turn::drive_turn(&prompt, files, bus).await
|
||||
};
|
||||
turn::emit_turn_end(bus, &outcome);
|
||||
bus.set_state(TurnState::Idle);
|
||||
// Ack only on a clean turn-end. `Failed` leaves every message popped
|
||||
// during the turn in the unacked list; next harness boot requeues them.
|
||||
if matches!(outcome, turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted) {
|
||||
ack_turn(socket).await;
|
||||
}
|
||||
if matches!(outcome, turn::TurnOutcome::RateLimited) {
|
||||
let secs = turn::rate_limit_sleep_secs();
|
||||
bus.emit_status("rate_limited");
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!("API rate-limited — sleeping {secs}s before retry"),
|
||||
});
|
||||
tracing::warn!(sleep_secs = secs, "rate-limited; parking");
|
||||
tokio::time::sleep(Duration::from_secs(secs)).await;
|
||||
requeue_inflight(socket).await;
|
||||
bus.emit_status("online");
|
||||
}
|
||||
// 401: flip into needs_login + requeue the message that triggered
|
||||
// the turn so it survives the re-auth. The serve loop's outer
|
||||
// login-state watcher parks until the operator's `/login` flow
|
||||
// completes; once it does, the requeued message replays the turn
|
||||
// (closes #419).
|
||||
if matches!(outcome, turn::TurnOutcome::AuthFailed) {
|
||||
bus.emit_status("needs_login_idle");
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "API 401 — waiting for re-login via web UI".into(),
|
||||
});
|
||||
tracing::warn!("auth-failed; parking until re-login");
|
||||
requeue_inflight(socket).await;
|
||||
}
|
||||
// Real crash: PromptTooLong is absorbed by compaction inside drive_turn.
|
||||
if let turn::TurnOutcome::Failed(e) = &outcome {
|
||||
notify_manager_of_failure(socket, label, e).await;
|
||||
}
|
||||
if let Some(stats) = stats {
|
||||
let ended_at = serve_common::now_unix();
|
||||
let duration_ms =
|
||||
i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
|
||||
let (open_threads, open_reminders) = fetch_agent_post_turn_counts(socket).await;
|
||||
let row = serve_common::build_row(
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_ms,
|
||||
model_at_start,
|
||||
from.clone(),
|
||||
&outcome,
|
||||
bus,
|
||||
open_threads,
|
||||
open_reminders,
|
||||
);
|
||||
stats.record(&row);
|
||||
}
|
||||
let pending = inbox_unread(socket).await;
|
||||
if pending > 0 {
|
||||
tracing::info!(%pending, "pending messages after turn; fetching next");
|
||||
}
|
||||
// `request_next_turn` MCP tool: agent wrote a sentinel requesting
|
||||
// an immediate self-continuation. Clear and inject synthetic wake.
|
||||
check_and_inject_continue(socket, label).await;
|
||||
matches!(outcome, turn::TurnOutcome::AuthFailed)
|
||||
}
|
||||
|
||||
// Per-turn user prompt: the role/tools/etc. is in the system prompt
|
||||
// (`prompts/agent.md` → `claude --system-prompt-file`); this is just the
|
||||
// wake signal claude reacts to. `unread` is the count of *other*
|
||||
// messages in the inbox right after this one was popped.
|
||||
// `redelivered` flags messages that were popped in a prior harness
|
||||
// session, never acked, and resurfaced after a restart — a banner
|
||||
// at the top of the wake prompt warns that any side-effects of
|
||||
// previous handling may already have happened.
|
||||
|
||||
/// Best-effort: tell the broker every message we popped during the
|
||||
/// turn is now fully handled (turn-end-OK). Swallows transport
|
||||
/// errors — the worst case is a redundant requeue on next boot.
|
||||
async fn ack_turn(socket: &Path) {
|
||||
match client::request::<_, AgentResponse>(socket, &AgentRequest::AckTurn).await {
|
||||
Ok(AgentResponse::Ok) => {}
|
||||
Ok(AgentResponse::Err { message }) => {
|
||||
tracing::warn!(%message, "ack_turn rejected by broker");
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::warn!(?other, "ack_turn unexpected response");
|
||||
}
|
||||
Err(e) => tracing::warn!(error = ?e, "ack_turn transport error"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot-time recovery: ask the broker to resurface anything we
|
||||
/// popped in a previous harness session but never acked. The broker
|
||||
/// resets `delivered_at = NULL` on those rows and remembers their
|
||||
/// ids so the next `Recv` carries `redelivered: true`. Swallows
|
||||
/// transport errors — they degrade to "no recovery this boot",
|
||||
/// which is no worse than the pre-feature behaviour (silent drop).
|
||||
async fn requeue_inflight(socket: &Path) {
|
||||
match client::request::<_, AgentResponse>(socket, &AgentRequest::RequeueInflight).await {
|
||||
Ok(AgentResponse::Ok) => {}
|
||||
Ok(AgentResponse::Err { message }) => {
|
||||
tracing::warn!(%message, "requeue_inflight rejected by broker");
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::warn!(?other, "requeue_inflight unexpected response");
|
||||
}
|
||||
Err(e) => tracing::warn!(error = ?e, "requeue_inflight transport error"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: tell the manager that this agent's last turn crashed
|
||||
/// (claude exited non-zero, compaction didn't help, etc.). Routed
|
||||
/// through the normal send path so the manager's inbox surfaces it
|
||||
/// as a system-style event; `label` is included explicitly in the
|
||||
/// body so the manager can identify the failing agent without having
|
||||
/// to look at the `from` field (which is broker-stamped and may
|
||||
/// differ from what the operator sees in the dashboard). Swallows
|
||||
/// transport errors — we just logged the failure, the worst case is
|
||||
/// the manager learns about the crash from the dashboard instead of
|
||||
/// inbox.
|
||||
async fn notify_manager_of_failure(socket: &Path, label: &str, err: &anyhow::Error) {
|
||||
let body = format!("[system] agent `{label}` claude turn failed:\n{err:#}");
|
||||
let res = client::request::<_, AgentResponse>(
|
||||
socket,
|
||||
&AgentRequest::Send {
|
||||
to: "manager".into(),
|
||||
body,
|
||||
in_reply_to: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = res {
|
||||
tracing::warn!(error = ?e, "failed to notify manager of turn failure");
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: ask our own per-agent socket how many messages are still
|
||||
/// pending after the wake-up Recv. Returns 0 if anything goes wrong.
|
||||
async fn inbox_unread(socket: &Path) -> u64 {
|
||||
match client::request::<_, AgentResponse>(socket, &AgentRequest::Status).await {
|
||||
Ok(AgentResponse::Status { unread }) => unread,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Best-effort: ask hive-c0re for this agent's open thread count + pending
|
||||
/// reminder count, after the turn finishes. Either roundtrip can fail
|
||||
/// (transport hiccup, race with hive-c0re restart) — in those cases we
|
||||
/// just drop a `None` into the stats row rather than blocking the loop.
|
||||
async fn fetch_agent_post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
|
||||
let threads = match client::request::<_, AgentResponse>(
|
||||
socket,
|
||||
&AgentRequest::GetLooseEnds,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(AgentResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
|
||||
_ => None,
|
||||
};
|
||||
let reminders = match client::request::<_, AgentResponse>(
|
||||
socket,
|
||||
&AgentRequest::CountPendingReminders,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(AgentResponse::PendingRemindersCount { count }) => Some(count),
|
||||
_ => None,
|
||||
};
|
||||
(threads, reminders)
|
||||
}
|
||||
|
||||
/// Check for the `request_next_turn` sentinel file. If present, remove it
|
||||
/// and inject a synthetic `from: "self", body: "continue"` message so the
|
||||
/// serve loop fires an immediate follow-up turn even when the inbox is empty.
|
||||
/// Best-effort: any I/O error is logged and ignored (the agent just waits
|
||||
/// for a real message as normal).
|
||||
async fn check_and_inject_continue(socket: &Path, label: &str) {
|
||||
let sentinel = hive_ag3nt::paths::state_dir().join("hyperhive-continue");
|
||||
if !sentinel.exists() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::remove_file(&sentinel) {
|
||||
tracing::warn!(error = %e, "check_and_inject_continue: remove sentinel failed");
|
||||
return;
|
||||
}
|
||||
// Sentinel was present: inject a wake so the outer loop fires immediately.
|
||||
// Route through the `Wake` request which is already wired in agent_server.
|
||||
let res = client::request::<_, AgentResponse>(
|
||||
socket,
|
||||
&AgentRequest::Wake {
|
||||
from: "self".into(),
|
||||
body: "continue".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match res {
|
||||
Ok(AgentResponse::Ok) => {
|
||||
tracing::info!(%label, "request_next_turn: injected self-continue wake");
|
||||
}
|
||||
Ok(AgentResponse::Err { message }) => {
|
||||
tracing::warn!(%message, "check_and_inject_continue: wake rejected");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "check_and_inject_continue: wake transport error");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
349
hive-ag3nt/src/bin/hive-m1nd.rs
Normal file
349
hive-ag3nt/src/bin/hive-m1nd.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
//! Manager harness. Talks to the manager socket (bind-mounted from the host
|
||||
//! at `/run/hive/mcp.sock` inside the `hm1nd` container). Two surfaces:
|
||||
//! `serve` (long-lived turn loop) and `mcp` (stdio MCP server claude spawns).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use hive_ag3nt::web_ui::TurnLock;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||
use hive_ag3nt::login::{self, LoginState};
|
||||
use hive_ag3nt::turn_stats::TurnStats;
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui};
|
||||
use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-m1nd", about = "hyperhive manager harness")]
|
||||
struct Cli {
|
||||
/// Path to the manager MCP socket (bind-mounted from the host).
|
||||
#[arg(long, global = true, default_value = DEFAULT_SOCKET)]
|
||||
socket: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Long-lived loop polling the manager inbox.
|
||||
Serve {
|
||||
#[arg(long, default_value_t = 1000)]
|
||||
poll_ms: u64,
|
||||
},
|
||||
/// Run the manager MCP server on stdio. Spawned by claude via
|
||||
/// `--mcp-config`; same shape as `hive-ag3nt mcp` but with the
|
||||
/// manager tool surface (`request_init_config`, `request_apply_commit`,
|
||||
/// `kill`, `start`, `restart`, `ask`, `answer`, `remind`, …).
|
||||
Mcp,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
Cmd::Serve { poll_ms } => {
|
||||
let port = std::env::var("HIVE_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(DEFAULT_WEB_PORT);
|
||||
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into());
|
||||
let claude_dir = login::default_dir();
|
||||
let initial = LoginState::from_dir(&claude_dir);
|
||||
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "hm1nd boot");
|
||||
let login_state = Arc::new(Mutex::new(initial));
|
||||
let bus = Bus::new();
|
||||
let stats = TurnStats::open_default();
|
||||
if let Some(s) = &stats {
|
||||
let (ctx, cost) = s.last_usage();
|
||||
if ctx.is_some() || cost.is_some() {
|
||||
bus.seed_usage(ctx, cost);
|
||||
}
|
||||
}
|
||||
let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Manager).await?;
|
||||
let turn_lock: TurnLock = Arc::new(tokio::sync::Mutex::new(()));
|
||||
plugins::install_configured(&cli.socket, None).await;
|
||||
tokio::spawn(web_ui::serve(
|
||||
label,
|
||||
port,
|
||||
login_state.clone(),
|
||||
bus.clone(),
|
||||
cli.socket.clone(),
|
||||
files.clone(),
|
||||
turn_lock.clone(),
|
||||
));
|
||||
tokio::spawn(hive_ag3nt::forge_notify::run(cli.socket.clone(), true));
|
||||
match initial {
|
||||
LoginState::Online => {
|
||||
serve(
|
||||
&cli.socket,
|
||||
Duration::from_millis(poll_ms),
|
||||
login_state,
|
||||
claude_dir,
|
||||
bus,
|
||||
stats,
|
||||
&files,
|
||||
turn_lock,
|
||||
)
|
||||
.await
|
||||
}
|
||||
LoginState::NeedsLogin => {
|
||||
turn::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await;
|
||||
serve(
|
||||
&cli.socket,
|
||||
Duration::from_millis(poll_ms),
|
||||
login_state,
|
||||
claude_dir,
|
||||
bus,
|
||||
stats,
|
||||
&files,
|
||||
turn_lock,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::Mcp => mcp::serve_manager_stdio(cli.socket).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn serve(
|
||||
socket: &Path,
|
||||
interval: Duration,
|
||||
login_state: Arc<Mutex<LoginState>>,
|
||||
claude_dir: std::path::PathBuf,
|
||||
bus: Bus,
|
||||
stats: Option<TurnStats>,
|
||||
files: &turn::TurnFiles,
|
||||
turn_lock: TurnLock,
|
||||
) -> Result<()> {
|
||||
tracing::info!(socket = %socket.display(), "hive-m1nd serve");
|
||||
// Same boot-time recovery as hive-ag3nt — see that loop for the
|
||||
// rationale. Manager-flavour socket so we requeue only manager
|
||||
// inflight rows.
|
||||
requeue_inflight(socket).await;
|
||||
loop {
|
||||
let recv: Result<ManagerResponse> =
|
||||
// Explicit long-poll: see hive-ag3nt's serve loop for the
|
||||
// rationale — recv now defaults to peek when wait_seconds
|
||||
// is None. `max: None` (= 1) keeps the serve loop driving
|
||||
// one turn per wake; claude calls recv(max: N) in-turn to
|
||||
// drain a burst when the wake prompt mentions pending.
|
||||
client::request(
|
||||
socket,
|
||||
&ManagerRequest::Recv {
|
||||
wait_seconds: Some(180),
|
||||
max: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match recv {
|
||||
Ok(ManagerResponse::Messages { messages }) if !messages.is_empty() => {
|
||||
let first = messages.into_iter().next().expect("checked non-empty");
|
||||
let auth_failed =
|
||||
handle_manager_turn(socket, &bus, stats.as_ref(), files, &turn_lock, first)
|
||||
.await;
|
||||
if auth_failed {
|
||||
*login_state.lock().unwrap() = LoginState::NeedsLogin;
|
||||
turn::wait_for_login(
|
||||
&claude_dir,
|
||||
login_state.clone(),
|
||||
&bus,
|
||||
u64::try_from(interval.as_millis()).unwrap_or(2000),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(ManagerResponse::Messages { .. }) => {
|
||||
// Idle: empty list = nothing pending. Brief sleep
|
||||
// before the next long-poll attempt.
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
Ok(
|
||||
ManagerResponse::Ok
|
||||
| ManagerResponse::Status { .. }
|
||||
| ManagerResponse::QuestionQueued { .. }
|
||||
| ManagerResponse::Recent { .. }
|
||||
| ManagerResponse::Logs { .. }
|
||||
| ManagerResponse::LooseEnds { .. }
|
||||
| ManagerResponse::PendingRemindersCount { .. }
|
||||
| ManagerResponse::ReminderRollup { .. }
|
||||
| ManagerResponse::AgentMeta { .. }
|
||||
| ManagerResponse::Schedules { .. },
|
||||
) => {
|
||||
tracing::warn!("recv produced unexpected response kind");
|
||||
}
|
||||
Ok(ManagerResponse::Err { message }) => {
|
||||
tracing::warn!(%message, "recv error");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "recv failed; retrying");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive one turn for a received manager-inbox message. Called from the
|
||||
/// serve loop for the non-empty-messages arm to keep that loop readable.
|
||||
/// Returns `true` when the turn ended with `AuthFailed` so the caller
|
||||
/// can park in `wait_for_login`.
|
||||
async fn handle_manager_turn(
|
||||
socket: &Path,
|
||||
bus: &Bus,
|
||||
stats: Option<&TurnStats>,
|
||||
files: &turn::TurnFiles,
|
||||
turn_lock: &TurnLock,
|
||||
first: hive_sh4re::DeliveredMessage,
|
||||
) -> bool {
|
||||
let from = first.from;
|
||||
let body = first.body;
|
||||
let redelivered = first.redelivered;
|
||||
if from == SYSTEM_SENDER {
|
||||
// Helper events (ApprovalResolved / Spawned / Rebuilt /
|
||||
// Killed / Destroyed) — surface in the live view and drive a
|
||||
// normal turn so the manager can react.
|
||||
let parsed = serde_json::from_str::<HelperEvent>(&body).ok();
|
||||
if let Some(event) = parsed {
|
||||
tracing::info!(?event, "helper event");
|
||||
} else {
|
||||
tracing::info!(%from, %body, "system message");
|
||||
}
|
||||
bus.emit(LiveEvent::Note { text: format!("[system] {body}") });
|
||||
}
|
||||
tracing::info!(%from, %body, %redelivered, "manager inbox");
|
||||
let unread = inbox_unread(socket).await;
|
||||
bus.emit(LiveEvent::TurnStart { from: from.clone(), body: body.clone(), unread });
|
||||
let prompt = serve_common::format_wake_prompt(&from, &body, unread, redelivered);
|
||||
bus.set_state(TurnState::Thinking);
|
||||
let started_at = serve_common::now_unix();
|
||||
let started_instant = std::time::Instant::now();
|
||||
let model_at_start = bus.model();
|
||||
let outcome = {
|
||||
let _guard = turn_lock.lock().await;
|
||||
turn::drive_turn(&prompt, files, bus).await
|
||||
};
|
||||
turn::emit_turn_end(bus, &outcome);
|
||||
bus.set_state(TurnState::Idle);
|
||||
// Ack only on a clean turn-end; Failed / RateLimited leave the
|
||||
// popped ids in-flight for the next boot's requeue.
|
||||
if matches!(outcome, turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted) {
|
||||
ack_turn(socket).await;
|
||||
}
|
||||
if matches!(outcome, turn::TurnOutcome::RateLimited) {
|
||||
let secs = turn::rate_limit_sleep_secs();
|
||||
bus.emit_status("rate_limited");
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!("API rate-limited — sleeping {secs}s before retry"),
|
||||
});
|
||||
tracing::warn!(sleep_secs = secs, "rate-limited; parking");
|
||||
tokio::time::sleep(Duration::from_secs(secs)).await;
|
||||
requeue_inflight(socket).await;
|
||||
bus.emit_status("online");
|
||||
}
|
||||
if matches!(outcome, turn::TurnOutcome::AuthFailed) {
|
||||
bus.emit_status("needs_login_idle");
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "API 401 — waiting for re-login via web UI".into(),
|
||||
});
|
||||
tracing::warn!("auth-failed; parking until re-login");
|
||||
requeue_inflight(socket).await;
|
||||
}
|
||||
if let Some(stats) = stats {
|
||||
let ended_at = serve_common::now_unix();
|
||||
let duration_ms =
|
||||
i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
|
||||
let (open_threads, open_reminders) = fetch_manager_post_turn_counts(socket).await;
|
||||
let row = serve_common::build_row(
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_ms,
|
||||
model_at_start,
|
||||
from.clone(),
|
||||
&outcome,
|
||||
bus,
|
||||
open_threads,
|
||||
open_reminders,
|
||||
);
|
||||
stats.record(&row);
|
||||
}
|
||||
let pending = inbox_unread(socket).await;
|
||||
if pending > 0 {
|
||||
tracing::info!(%pending, "pending messages after turn; fetching next");
|
||||
}
|
||||
matches!(outcome, turn::TurnOutcome::AuthFailed)
|
||||
}
|
||||
|
||||
/// Best-effort: tell the broker every message popped during the turn
|
||||
/// is now handled. Mirror of `hive-ag3nt::ack_turn` on the manager
|
||||
/// surface.
|
||||
async fn ack_turn(socket: &Path) {
|
||||
match client::request::<_, ManagerResponse>(socket, &ManagerRequest::AckTurn).await {
|
||||
Ok(ManagerResponse::Ok) => {}
|
||||
Ok(ManagerResponse::Err { message }) => {
|
||||
tracing::warn!(%message, "ack_turn rejected by broker");
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::warn!(?other, "ack_turn unexpected response");
|
||||
}
|
||||
Err(e) => tracing::warn!(error = ?e, "ack_turn transport error"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot-time recovery: ask the broker to resurface any inflight (popped
|
||||
/// but not acked) messages so the next `Recv` re-delivers them with
|
||||
/// the redelivery banner. Mirror of `hive-ag3nt::requeue_inflight`.
|
||||
async fn requeue_inflight(socket: &Path) {
|
||||
match client::request::<_, ManagerResponse>(socket, &ManagerRequest::RequeueInflight).await {
|
||||
Ok(ManagerResponse::Ok) => {}
|
||||
Ok(ManagerResponse::Err { message }) => {
|
||||
tracing::warn!(%message, "requeue_inflight rejected by broker");
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::warn!(?other, "requeue_inflight unexpected response");
|
||||
}
|
||||
Err(e) => tracing::warn!(error = ?e, "requeue_inflight transport error"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn inbox_unread(socket: &Path) -> u64 {
|
||||
match client::request::<_, ManagerResponse>(socket, &ManagerRequest::Status).await {
|
||||
Ok(ManagerResponse::Status { unread }) => unread,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Manager-flavour equivalent of the agent helper. Mirror shape, just
|
||||
/// uses ManagerRequest/ManagerResponse instead of the agent variants.
|
||||
async fn fetch_manager_post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
|
||||
let threads = match client::request::<_, ManagerResponse>(
|
||||
socket,
|
||||
&ManagerRequest::GetLooseEnds { agent: None },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ManagerResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
|
||||
_ => None,
|
||||
};
|
||||
let reminders = match client::request::<_, ManagerResponse>(
|
||||
socket,
|
||||
&ManagerRequest::CountPendingReminders { agent: None },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ManagerResponse::PendingRemindersCount { count }) => Some(count),
|
||||
_ => None,
|
||||
};
|
||||
(threads, reminders)
|
||||
}
|
||||
|
||||
124
hive-ag3nt/src/client.rs
Normal file
124
hive-ag3nt/src/client.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
/// Backoff schedule between attempts. Five entries → up to 5 retries on
|
||||
/// top of the initial attempt; total wall-clock cap = 2+4+8+16+30 = 60s.
|
||||
/// Sized to ride out a hive-c0re restart (systemd usually has the unix
|
||||
/// socket back inside ~5s) without the agent-side claude session having
|
||||
/// to handle the transient itself — burning tokens on a tool-error retry
|
||||
/// loop is more expensive than 60s of in-harness sleep.
|
||||
const RETRY_BACKOFFS_MS: &[u64] = &[2_000, 4_000, 8_000, 16_000, 30_000];
|
||||
|
||||
/// Transparent retry wrapper around [`request_retried`] that throws away
|
||||
/// the retry count. Use this from non-tool callers (the harness serve
|
||||
/// loop, web UI, CLI subcommands) where we just want the socket-restart
|
||||
/// resilience without surfacing the bookkeeping.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the socket is unreachable after all retries, or if
|
||||
/// serialization / deserialization of the request or response fails.
|
||||
pub async fn request<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp>
|
||||
where
|
||||
Req: Serialize + ?Sized,
|
||||
Resp: DeserializeOwned,
|
||||
{
|
||||
request_retried(socket, req).await.map(|(resp, _)| resp)
|
||||
}
|
||||
|
||||
/// Same wire shape as [`request`], but reports how many retries it took
|
||||
/// past the initial attempt (0 = succeeded first try). MCP tool handlers
|
||||
/// use this so they can append a one-line hint to the tool result when
|
||||
/// retries happened — that way claude knows the prior socket flake
|
||||
/// wasn't a content error and shouldn't trigger an LLM-level retry of
|
||||
/// its own.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if all retries are exhausted, or on a fatal protocol
|
||||
/// error (serialization / deserialization failure).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `RETRY_BACKOFFS_MS.len()` does not fit in a `u32`, which
|
||||
/// cannot happen with the current compile-time constant.
|
||||
pub async fn request_retried<Req, Resp>(socket: &Path, req: &Req) -> Result<(Resp, u32)>
|
||||
where
|
||||
Req: Serialize + ?Sized,
|
||||
Resp: DeserializeOwned,
|
||||
{
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
let max_retries = u32::try_from(RETRY_BACKOFFS_MS.len()).unwrap();
|
||||
for attempt in 0..=max_retries {
|
||||
match try_once::<Req, Resp>(socket, req).await {
|
||||
Ok(resp) => return Ok((resp, attempt)),
|
||||
Err(RequestError::Fatal(e)) => return Err(e),
|
||||
Err(RequestError::Transient(e)) => {
|
||||
if attempt < max_retries {
|
||||
let sleep_ms = RETRY_BACKOFFS_MS[attempt as usize];
|
||||
tracing::warn!(
|
||||
attempt = attempt + 1,
|
||||
sleep_ms,
|
||||
error = %e,
|
||||
"hive socket attempt failed; retrying"
|
||||
);
|
||||
last_err = Some(e);
|
||||
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
|
||||
} else {
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow!("hive socket: retries exhausted")))
|
||||
}
|
||||
|
||||
/// Transient = connect / IO error worth a retry (server restart, broken
|
||||
/// pipe). Fatal = serialization / deserialization / protocol error
|
||||
/// where retrying would just repeat the same failure.
|
||||
enum RequestError {
|
||||
Transient(anyhow::Error),
|
||||
Fatal(anyhow::Error),
|
||||
}
|
||||
|
||||
async fn try_once<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp, RequestError>
|
||||
where
|
||||
Req: Serialize + ?Sized,
|
||||
Resp: DeserializeOwned,
|
||||
{
|
||||
let stream = UnixStream::connect(socket)
|
||||
.await
|
||||
.with_context(|| format!("connect to {}", socket.display()))
|
||||
.map_err(RequestError::Transient)?;
|
||||
let (read, mut write) = stream.into_split();
|
||||
|
||||
let mut payload = serde_json::to_string(req).map_err(|e| RequestError::Fatal(e.into()))?;
|
||||
payload.push('\n');
|
||||
write
|
||||
.write_all(payload.as_bytes())
|
||||
.await
|
||||
.map_err(|e| RequestError::Transient(e.into()))?;
|
||||
write
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| RequestError::Transient(e.into()))?;
|
||||
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
let read_bytes = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| RequestError::Transient(e.into()))?;
|
||||
if read_bytes == 0 || line.is_empty() {
|
||||
return Err(RequestError::Transient(anyhow!(
|
||||
"server closed connection without responding"
|
||||
)));
|
||||
}
|
||||
serde_json::from_str(line.trim()).map_err(|e| RequestError::Fatal(e.into()))
|
||||
}
|
||||
769
hive-ag3nt/src/events.rs
Normal file
769
hive-ag3nt/src/events.rs
Normal file
|
|
@ -0,0 +1,769 @@
|
|||
//! Live event stream for the per-agent web UI. The harness emits one
|
||||
//! `LiveEvent` per interesting thing that happens during a turn — wake-up
|
||||
//! (the popped inbox message), every line claude prints on stdout
|
||||
//! (parsed from `--output-format stream-json`), and the turn-end summary.
|
||||
//! The web UI subscribes via SSE and renders rows live.
|
||||
//!
|
||||
//! Channel type is `tokio::sync::broadcast`. New subscribers see only
|
||||
//! future events; the dashboard JS deals with the cold-start case by
|
||||
//! showing "connecting…" until the first event arrives.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
const CHANNEL_CAPACITY: usize = 256;
|
||||
/// Max `LiveEvent`s the `Bus` returns from `history()` and keeps in
|
||||
/// sqlite. Older rows are vacuumed on a periodic sweep.
|
||||
const HISTORY_CAPACITY: usize = 2000;
|
||||
/// Path to the persisted event db. Overridable via `HYPERHIVE_EVENTS_DB`
|
||||
/// for dev / tests; otherwise derived from the agent's state dir.
|
||||
fn events_db_path() -> PathBuf {
|
||||
std::env::var_os("HYPERHIVE_EVENTS_DB").map_or_else(
|
||||
|| crate::paths::state_dir().join("hyperhive-events.sqlite"),
|
||||
PathBuf::from,
|
||||
)
|
||||
}
|
||||
|
||||
/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE`
|
||||
/// for dev / tests; otherwise derived from the agent's state dir.
|
||||
fn model_file_path() -> PathBuf {
|
||||
std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else(
|
||||
|| crate::paths::state_dir().join("hyperhive-model"),
|
||||
PathBuf::from,
|
||||
)
|
||||
}
|
||||
|
||||
fn load_model() -> Option<String> {
|
||||
let s = std::fs::read_to_string(model_file_path()).ok()?;
|
||||
let name = s.trim();
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_model(name: &str) -> std::io::Result<()> {
|
||||
let path = model_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
std::fs::write(path, format!("{name}\n"))
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts);
|
||||
";
|
||||
|
||||
/// Envelope carried over the broadcast channel: the `LiveEvent` itself
|
||||
/// plus a monotonic per-process seq stamped by `Bus::emit`. SSE consumers
|
||||
/// serialize this directly (seq becomes a sibling of the `kind` tag);
|
||||
/// clients use seq to dedupe their buffered live traffic against the
|
||||
/// snapshot/history responses (drop anything with `seq <= snapshot.seq`).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BusEvent {
|
||||
pub seq: u64,
|
||||
#[serde(flatten)]
|
||||
pub event: LiveEvent,
|
||||
}
|
||||
|
||||
/// One row of the agent's live stream. Serialised to JSON for SSE delivery.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum LiveEvent {
|
||||
/// Harness popped a wake-up message and is about to invoke claude.
|
||||
/// `unread` is the count of *other* messages still in the inbox at
|
||||
/// that moment — surfaced as a badge in the live panel header.
|
||||
TurnStart {
|
||||
from: String,
|
||||
body: String,
|
||||
unread: u64,
|
||||
},
|
||||
/// One line of claude's `--output-format stream-json` stdout, parsed as
|
||||
/// a generic JSON value (so we don't have to track every claude-code
|
||||
/// event variant). The frontend pretty-prints by `type` field.
|
||||
Stream(serde_json::Value),
|
||||
/// Free-form note from the harness (e.g. "claude exited 0",
|
||||
/// "stream-json parse error: ..."). Useful when stream-json itself
|
||||
/// fails so the UI doesn't just go silent.
|
||||
///
|
||||
/// Must be a struct variant (not `Note(String)`): internally-tagged
|
||||
/// enums can't flatten a tag onto a primitive newtype, and serde
|
||||
/// fails serialization at runtime — silently, because the SSE
|
||||
/// handler's `filter_map(... .ok()? ...)` swallows the error. From
|
||||
/// 2025-08 through 2026-05 every `Note` emission was a no-op + the
|
||||
/// sqlite history persisted them as the literal string `"null"`.
|
||||
/// The web UI's `note` renderer already reads `ev.text`, so the
|
||||
/// wire shape matches without a JS change.
|
||||
Note { text: String },
|
||||
/// Turn finished. `ok=false` means claude exited non-zero or the
|
||||
/// harness hit a transport error.
|
||||
TurnEnd { ok: bool, note: Option<String> },
|
||||
/// Harness reachability flipped: `"online"` /
|
||||
/// `"needs_login_idle"` / `"needs_login_in_progress"`. The web UI
|
||||
/// drives the alive badge from this so the operator sees a login
|
||||
/// land (or get revoked) without polling. Session detail
|
||||
/// (`url`/`output`/`finished`) is still served by `/api/state`
|
||||
/// during the short-lived in-progress window — the client
|
||||
/// re-fetches only while that flow is active.
|
||||
StatusChanged { status: String },
|
||||
/// `/api/model` switched the active claude model. The web UI
|
||||
/// updates the chip + the per-turn stats sink will key off this
|
||||
/// to mark the boundary in its log.
|
||||
ModelChanged { model: String },
|
||||
/// Token usage for the turn just ended. Carries two snapshots:
|
||||
/// - `ctx` is the LAST inference's usage block (the actual context
|
||||
/// window in use right now — what the operator needs to decide
|
||||
/// whether to compact / reset).
|
||||
/// - `cost` is the cumulative usage across every inference in the
|
||||
/// turn (sum of per-call billed tokens — the cost signal). For
|
||||
/// tool-heavy turns the cumulative blows past the model's window
|
||||
/// because each tool call's prompt is rebilled.
|
||||
TokenUsageChanged { ctx: TokenUsage, cost: TokenUsage },
|
||||
/// Harness's `TurnState` transitioned (idle / thinking /
|
||||
/// compacting). `since_unix` matches `Bus::state_snapshot().1`
|
||||
/// so the client's elapsed-time ticker keeps progressing across
|
||||
/// SSE reconnects without drift.
|
||||
TurnStateChanged {
|
||||
state: TurnState,
|
||||
since_unix: i64,
|
||||
},
|
||||
}
|
||||
|
||||
/// sqlite-backed event log. Wraps a `Connection` behind a `Mutex` so the
|
||||
/// `Bus` (which clones cheaply) shares one writer.
|
||||
struct EventStore {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl EventStore {
|
||||
fn open(path: &Path) -> rusqlite::Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
fn append(&self, event: &LiveEvent) -> rusqlite::Result<()> {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0);
|
||||
let kind = match event {
|
||||
LiveEvent::TurnStart { .. } => "turn_start",
|
||||
LiveEvent::Stream(_) => "stream",
|
||||
LiveEvent::Note { .. } => "note",
|
||||
LiveEvent::TurnEnd { .. } => "turn_end",
|
||||
LiveEvent::StatusChanged { .. } => "status_changed",
|
||||
LiveEvent::ModelChanged { .. } => "model_changed",
|
||||
LiveEvent::TokenUsageChanged { .. } => "token_usage_changed",
|
||||
LiveEvent::TurnStateChanged { .. } => "turn_state_changed",
|
||||
};
|
||||
let payload = serde_json::to_string(event).unwrap_or_else(|_| "null".into());
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO events (ts, kind, payload_json) VALUES (?1, ?2, ?3)",
|
||||
params![ts, kind, payload],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recent(&self, limit: usize) -> rusqlite::Result<Vec<LiveEvent>> {
|
||||
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT payload_json FROM events
|
||||
ORDER BY id DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit_i], |row| {
|
||||
let s: String = row.get(0)?;
|
||||
Ok(serde_json::from_str::<LiveEvent>(&s).ok())
|
||||
})?;
|
||||
let mut out: Vec<LiveEvent> = rows.flatten().flatten().collect();
|
||||
out.reverse();
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Token usage emitted by claude in the final `result` stream-json event.
|
||||
/// All counts are in tokens. `None` fields mean the server didn't report them.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TokenUsage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
}
|
||||
|
||||
impl TokenUsage {
|
||||
/// Total context consumed this turn (input + cache reads + cache writes).
|
||||
/// This is the per-inference context footprint that counts against the
|
||||
/// model's `contextWindow` limit. Tracked from the last `assistant` event
|
||||
/// in the stream-json (per-inference usage, not the cumulative `result`
|
||||
/// event which sums across all inferences in a tool-heavy turn and can
|
||||
/// far exceed the per-inference window).
|
||||
#[must_use]
|
||||
pub fn context_tokens(&self) -> u64 {
|
||||
self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens
|
||||
}
|
||||
|
||||
/// Parse usage from the terminal `result` stream-json event. This is the
|
||||
/// **cumulative** sum across every inference in the turn — useful as a
|
||||
/// cost signal, but NOT the current context size (a tool-heavy turn
|
||||
/// sums per-call cached prompts and easily exceeds the model window).
|
||||
#[must_use]
|
||||
pub fn from_stream_event(v: &serde_json::Value) -> Option<Self> {
|
||||
if v.get("type").and_then(|t| t.as_str()) != Some("result") {
|
||||
return None;
|
||||
}
|
||||
Some(Self::from_usage_obj(v.get("usage")?))
|
||||
}
|
||||
|
||||
/// Parse usage from a per-inference `assistant` event's
|
||||
/// `.message.usage` block. Each turn fires one of these for every
|
||||
/// model call; tracking the LAST one over the turn gives the actual
|
||||
/// conversation context size — the number to watch for compaction.
|
||||
#[must_use]
|
||||
pub fn from_assistant_event(v: &serde_json::Value) -> Option<Self> {
|
||||
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
|
||||
return None;
|
||||
}
|
||||
Some(Self::from_usage_obj(v.get("message")?.get("usage")?))
|
||||
}
|
||||
|
||||
fn from_usage_obj(u: &serde_json::Value) -> Self {
|
||||
let field = |k: &str| u.get(k).and_then(serde_json::Value::as_u64).unwrap_or(0);
|
||||
Self {
|
||||
input_tokens: field("input_tokens"),
|
||||
output_tokens: field("output_tokens"),
|
||||
cache_read_input_tokens: field("cache_read_input_tokens"),
|
||||
cache_creation_input_tokens: field("cache_creation_input_tokens"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the per-inference context-window limit from a `result`
|
||||
/// stream-json event's `modelUsage` map. The API reports this as
|
||||
/// `modelUsage.<model-name>.contextWindow`; we take the first non-zero
|
||||
/// value across all model keys.
|
||||
///
|
||||
/// Returns `None` if the event is not a `result` type or has no
|
||||
/// `contextWindow` field. The returned value is the authoritative
|
||||
/// per-inference active window (e.g. 200 000 for `claude-sonnet-4-6`).
|
||||
/// It may be smaller than the full prompt-cache capacity (which can
|
||||
/// be several million tokens via cache reads).
|
||||
#[must_use]
|
||||
pub fn context_window_from_result_event(v: &serde_json::Value) -> Option<u64> {
|
||||
if v.get("type").and_then(|t| t.as_str()) != Some("result") {
|
||||
return None;
|
||||
}
|
||||
let model_usage = v.get("modelUsage")?;
|
||||
let map = model_usage.as_object()?;
|
||||
for (_model, stats) in map {
|
||||
if let Some(w) = stats.get("contextWindow").and_then(serde_json::Value::as_u64) {
|
||||
if w > 0 {
|
||||
return Some(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Authoritative turn-loop state. The harness owns it; the web UI
|
||||
/// reads via `/api/state` and renders. Lives alongside the bus
|
||||
/// because everyone who has a `Bus` already has the right handle to
|
||||
/// poke the state on transitions.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TurnState {
|
||||
/// Inbox is empty / waiting on `Recv`.
|
||||
Idle,
|
||||
/// `claude --print` is running for a turn.
|
||||
Thinking,
|
||||
/// Operator-triggered `/compact` is running on the persistent
|
||||
/// session.
|
||||
Compacting,
|
||||
}
|
||||
|
||||
/// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a
|
||||
/// persisted runtime override is present.
|
||||
pub const DEFAULT_MODEL: &str = "haiku";
|
||||
|
||||
/// Return the model declared in `HIVE_DEFAULT_MODEL` (set from
|
||||
/// `hyperhive.model` in `agent.nix`), or `None` if the env var is absent /
|
||||
/// empty. When `Some`, this takes precedence over any persisted runtime
|
||||
/// override so that nix config changes always take effect on rebuild.
|
||||
#[must_use]
|
||||
pub fn configured_model() -> Option<&'static str> {
|
||||
// Leak once at startup — acceptable for a single config value.
|
||||
std::env::var("HIVE_DEFAULT_MODEL")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| &*Box::leak(s.into_boxed_str()))
|
||||
}
|
||||
|
||||
/// Return the model to use when no config and no persisted override exist.
|
||||
#[must_use]
|
||||
pub fn default_model() -> &'static str {
|
||||
configured_model().unwrap_or(DEFAULT_MODEL)
|
||||
}
|
||||
|
||||
/// Context-window size in tokens for a given model name.
|
||||
///
|
||||
/// Canonical per-model sizes are declared in `harness-base.nix` as
|
||||
/// `hyperhive.contextWindowTokens` and injected as
|
||||
/// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars — so this function
|
||||
/// normally just reads them. The Rust code carries no model knowledge;
|
||||
/// updating model families only requires a Nix change.
|
||||
///
|
||||
/// Resolution order (first match wins):
|
||||
/// 1. `HIVE_CONTEXT_WINDOW_TOKENS_<KEY>` — key (lowercased) is a
|
||||
/// substring of the active model name. Populated by the Nix default
|
||||
/// map for all known families; add/override in `agent.nix`.
|
||||
/// 2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override (any model).
|
||||
/// 3. Hard fallback: `200_000` (conservative; only hit outside NixOS).
|
||||
#[must_use]
|
||||
pub fn context_window_tokens(model: &str) -> u64 {
|
||||
let m = model.to_ascii_lowercase();
|
||||
// Per-model env vars set by `hyperhive.contextWindowTokens` in Nix.
|
||||
for (key, val) in std::env::vars() {
|
||||
if let Some(suffix) = key.strip_prefix("HIVE_CONTEXT_WINDOW_TOKENS_")
|
||||
&& !suffix.is_empty() && m.contains(&suffix.to_ascii_lowercase())
|
||||
&& let Ok(v) = val.trim().parse::<u64>()
|
||||
&& v > 0 {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
// Global override (single value, any model).
|
||||
if let Ok(s) = std::env::var("HIVE_CONTEXT_WINDOW_TOKENS")
|
||||
&& let Ok(v) = s.trim().parse::<u64>()
|
||||
&& v > 0 {
|
||||
return v;
|
||||
}
|
||||
// Hard fallback for dev/test outside NixOS where env vars aren't set.
|
||||
200_000
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bus {
|
||||
tx: Arc<broadcast::Sender<BusEvent>>,
|
||||
/// Monotonic per-process counter stamped onto every `BusEvent`.
|
||||
/// Persisted nowhere — a harness restart resets seq to 0; clients
|
||||
/// always treat reconnect as "fresh state, fresh stream of seqs."
|
||||
/// Historical events served from sqlite carry no seq (they predate
|
||||
/// the live channel the seq is meant to dedupe against).
|
||||
event_seq: Arc<AtomicU64>,
|
||||
/// Persistent event log. `None` only if opening the sqlite db failed
|
||||
/// at construction — we keep going so the harness doesn't die on a
|
||||
/// missing state dir mount in dev / test scenarios.
|
||||
store: Option<Arc<EventStore>>,
|
||||
/// Current turn-loop state + since-when (unix seconds).
|
||||
state: Arc<Mutex<(TurnState, i64)>>,
|
||||
/// Model name passed to `claude --model`. Default `haiku`; the
|
||||
/// operator can override at runtime via `POST /api/model`.
|
||||
model: Arc<Mutex<String>>,
|
||||
/// Last-inference token usage from the most recent turn's final
|
||||
/// `assistant` event. Represents the actual context window size at
|
||||
/// turn-end — the number the operator watches to decide whether to
|
||||
/// compact. `None` until the first turn completes.
|
||||
last_ctx_usage: Arc<Mutex<Option<TokenUsage>>>,
|
||||
/// Cumulative token usage from the most recent turn's `result`
|
||||
/// event (sum across every inference in the turn). This is the cost
|
||||
/// signal — tool-heavy turns rebill the cached prompt per call and
|
||||
/// blow past the model window. `None` until the first turn completes.
|
||||
last_cost_usage: Arc<Mutex<Option<TokenUsage>>>,
|
||||
/// True while the harness is parked after a rate-limit response.
|
||||
/// Set by `emit_status("rate_limited")`, cleared by
|
||||
/// `emit_status("online")`. Also mirrored to a sentinel file at
|
||||
/// `{state_dir}/hyperhive-rate-limited` so the host-side
|
||||
/// `container_view` can surface the status on the dashboard without
|
||||
/// a live socket call.
|
||||
rate_limited: Arc<AtomicBool>,
|
||||
/// One-shot: next `run_claude` call drops `--continue`, starting
|
||||
/// a fresh claude session. Set by `POST /api/new-session` from
|
||||
/// the per-agent web UI; consumed (cleared back to false) by the
|
||||
/// next turn. Subsequent turns resume normal `--continue`
|
||||
/// behavior. Atomic so the consumer can take-and-clear without a
|
||||
/// lock.
|
||||
skip_continue_once: Arc<AtomicBool>,
|
||||
/// Per-turn tool-call counter. Reset by the bin loop between
|
||||
/// turns via `take_tool_calls`. Populated by `observe_stream` as
|
||||
/// the stdout pump parses each stream-json line. Powers the
|
||||
/// `tool_call_count` + `tool_call_breakdown_json` columns on the
|
||||
/// per-turn stats sink.
|
||||
tool_calls: Arc<Mutex<std::collections::HashMap<String, u64>>>,
|
||||
/// Unix timestamp of the most recent completed turn (set by
|
||||
/// `record_turn_usage`). Used by the auto-reset heuristic in
|
||||
/// `turn.rs` to compute how long the session has been idle and
|
||||
/// whether the prompt cache has gone cold. `0` = no turn yet.
|
||||
last_turn_ended_unix: Arc<AtomicI64>,
|
||||
/// Per-inference context-window size as reported by the Anthropic API
|
||||
/// in the stream-json `result` event (`modelUsage.*.contextWindow`).
|
||||
/// Set by the stdout pump on every completed turn. Takes precedence
|
||||
/// over the Nix-configured `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars
|
||||
/// for compaction watermark calculations — it reflects the actual
|
||||
/// limit the model enforces, which may differ from what the operator
|
||||
/// configured (e.g. 200 k active window on a 1 M cache-enabled model).
|
||||
api_context_window: Arc<Mutex<Option<u64>>>,
|
||||
}
|
||||
|
||||
impl Bus {
|
||||
/// Open the events db (path from `events_db_path()`). On failure, fall back
|
||||
/// to a no-store bus — the harness still works, just without persistent history.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
let path = events_db_path();
|
||||
let store = match EventStore::open(&path) {
|
||||
Ok(s) => Some(Arc::new(s)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, path = %path.display(), "events db open failed; running without history");
|
||||
None
|
||||
}
|
||||
};
|
||||
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
|
||||
// Priority: HIVE_DEFAULT_MODEL (from hyperhive.model in agent.nix) >
|
||||
// persisted runtime override > compiled-in DEFAULT_MODEL.
|
||||
// The nix config always wins on rebuild; the persisted file is kept
|
||||
// for within-session tracking only (see persist_model / set_model).
|
||||
let initial_model = configured_model()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()));
|
||||
// Restore rate_limited from the sentinel file — if the harness
|
||||
// crashed while parked, we should still show the right status on
|
||||
// cold load until the next turn clears it.
|
||||
let sentinel = crate::paths::state_dir().join("hyperhive-rate-limited");
|
||||
let was_rate_limited = sentinel.exists();
|
||||
Self {
|
||||
tx: Arc::new(tx),
|
||||
event_seq: Arc::new(AtomicU64::new(0)),
|
||||
store,
|
||||
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
|
||||
model: Arc::new(Mutex::new(initial_model)),
|
||||
last_ctx_usage: Arc::new(Mutex::new(None)),
|
||||
last_cost_usage: Arc::new(Mutex::new(None)),
|
||||
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
|
||||
skip_continue_once: Arc::new(AtomicBool::new(false)),
|
||||
tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
last_turn_ended_unix: Arc::new(AtomicI64::new(0)),
|
||||
api_context_window: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Current high-water seq. Snapshot endpoints read this before
|
||||
/// gathering state so the resulting (snapshot.seq, snapshot) pair
|
||||
/// satisfies: any live event with seq > snapshot.seq is post-snapshot
|
||||
/// (not yet reflected). Clients dedupe buffered SSE traffic against
|
||||
/// this value.
|
||||
#[must_use]
|
||||
pub fn current_seq(&self) -> u64 {
|
||||
self.event_seq.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn next_seq(&self) -> u64 {
|
||||
self.event_seq.fetch_add(1, Ordering::SeqCst) + 1
|
||||
}
|
||||
|
||||
/// Arm the one-shot: the next claude invocation will run without
|
||||
/// `--continue`, dropping any prior session context. Idempotent
|
||||
/// — calling twice in a row before the next turn still consumes
|
||||
/// to a single fresh-start.
|
||||
pub fn request_new_session(&self) {
|
||||
self.skip_continue_once.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Take + clear the one-shot. Returns true iff the caller should
|
||||
/// run claude without `--continue` for this turn.
|
||||
#[must_use]
|
||||
pub fn take_skip_continue(&self) -> bool {
|
||||
self.skip_continue_once.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Currently-selected claude model name. Read on every turn so a
|
||||
/// `/model <name>` flip takes effect on the next turn.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn model(&self) -> String {
|
||||
self.model.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Switch the model for future turns. The current turn (if any)
|
||||
/// keeps the model it was already running. Persisted to the agent's
|
||||
/// state dir (`hyperhive-model`) so the override survives harness
|
||||
/// restart and container rebuild (gone on `--purge`, matching
|
||||
/// every other piece of agent state).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_model(&self, name: impl Into<String>) {
|
||||
let value: String = name.into();
|
||||
self.model.lock().unwrap().clone_from(&value);
|
||||
if let Err(e) = persist_model(&value) {
|
||||
tracing::warn!(error = ?e, "model: persist failed");
|
||||
}
|
||||
self.emit(LiveEvent::ModelChanged { model: value });
|
||||
}
|
||||
|
||||
/// Seed `last_ctx_usage` + `last_cost_usage` at startup without
|
||||
/// emitting a SSE event. Used by the bin entrypoints to backfill
|
||||
/// from the most recent `turn_stats` row so the per-agent web UI's
|
||||
/// ctx + cost badges paint real numbers on cold load.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if an internal lock is poisoned.
|
||||
pub fn seed_usage(&self, ctx: Option<TokenUsage>, cost: Option<TokenUsage>) {
|
||||
if ctx.is_some() {
|
||||
*self.last_ctx_usage.lock().unwrap() = ctx;
|
||||
}
|
||||
if cost.is_some() {
|
||||
*self.last_cost_usage.lock().unwrap() = cost;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the just-ended turn's usage. `ctx` is the last inference's
|
||||
/// usage (current context size); `cost` is the cumulative across
|
||||
/// every inference in the turn (cost signal). One SSE event fires
|
||||
/// per turn carrying both.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if an internal lock is poisoned.
|
||||
pub fn record_turn_usage(&self, ctx: TokenUsage, cost: TokenUsage) {
|
||||
*self.last_ctx_usage.lock().unwrap() = Some(ctx);
|
||||
*self.last_cost_usage.lock().unwrap() = Some(cost);
|
||||
self.last_turn_ended_unix.store(now_unix(), Ordering::Relaxed);
|
||||
self.emit(LiveEvent::TokenUsageChanged { ctx, cost });
|
||||
}
|
||||
|
||||
/// Unix timestamp of the most recent completed turn (`record_turn_usage`
|
||||
/// call), or `0` if no turn has finished yet.
|
||||
#[must_use]
|
||||
pub fn last_turn_ended_unix(&self) -> i64 {
|
||||
self.last_turn_ended_unix.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Update the API-reported context-window size from the stream-json
|
||||
/// `result` event's `modelUsage.*.contextWindow` field. Called by the
|
||||
/// stdout pump once per completed turn. `0` is ignored (sentinel for
|
||||
/// "not reported").
|
||||
pub fn set_api_context_window(&self, window: u64) {
|
||||
if window > 0 {
|
||||
*self.api_context_window.lock().unwrap() = Some(window);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the API-reported per-inference context-window size, if the
|
||||
/// harness has seen at least one completed turn for this session.
|
||||
/// `None` until the first result event is processed.
|
||||
#[must_use]
|
||||
pub fn api_context_window(&self) -> Option<u64> {
|
||||
*self.api_context_window.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Walk a stream-json value for `tool_use` blocks and bump the
|
||||
/// per-turn counter for each one we find. Called by the stdout
|
||||
/// pump on every parsed line. Cheap when the line isn't an
|
||||
/// assistant message — the field-check short-circuits.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn observe_stream(&self, v: &serde_json::Value) {
|
||||
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
|
||||
return;
|
||||
}
|
||||
let Some(content) = v
|
||||
.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut counts = self.tool_calls.lock().unwrap();
|
||||
for block in content {
|
||||
if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
|
||||
continue;
|
||||
}
|
||||
let name = block
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("<unnamed>")
|
||||
.to_owned();
|
||||
*counts.entry(name).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot + clear the per-turn tool-call counter. The harness
|
||||
/// calls this between turns to fold the breakdown into a
|
||||
/// `turn_stats` row, then start the next turn with an empty map.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn take_tool_calls(&self) -> std::collections::HashMap<String, u64> {
|
||||
std::mem::take(&mut *self.tool_calls.lock().unwrap())
|
||||
}
|
||||
|
||||
/// Last context-size snapshot (last inference of the most recent
|
||||
/// turn), or `None` if no turn has completed yet.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn last_ctx_usage(&self) -> Option<TokenUsage> {
|
||||
*self.last_ctx_usage.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Last cumulative cost snapshot (sum across the most recent turn's
|
||||
/// inferences), or `None` if no turn has completed yet.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn last_cost_usage(&self) -> Option<TokenUsage> {
|
||||
*self.last_cost_usage.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Update the harness's authoritative turn-loop state. Records
|
||||
/// the transition time so `state_snapshot` can return a since-age.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_state(&self, next: TurnState) {
|
||||
let since;
|
||||
{
|
||||
let mut guard = self.state.lock().unwrap();
|
||||
if guard.0 == next {
|
||||
return;
|
||||
}
|
||||
*guard = (next, now_unix());
|
||||
since = guard.1;
|
||||
}
|
||||
self.emit(LiveEvent::TurnStateChanged {
|
||||
state: next,
|
||||
since_unix: since,
|
||||
});
|
||||
}
|
||||
|
||||
/// Broadcast a status flip (online / `needs_login_*` / `rate_limited`).
|
||||
/// Called by the bin entry points + `turn::wait_for_login` + the
|
||||
/// `post_login_*` handlers — every site that mutates the
|
||||
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
|
||||
/// drops its periodic /api/state poll while a turn loop is running.
|
||||
///
|
||||
/// Sentinel files survive harness restart so the host-side dashboard
|
||||
/// can render the status without a live socket call:
|
||||
/// - `"rate_limited"` writes `{state_dir}/hyperhive-rate-limited`
|
||||
/// (cleared by any other status).
|
||||
/// - `"needs_login_idle"` writes `{state_dir}/hyperhive-needs-login`
|
||||
/// so a 401-triggered re-auth flag persists across harness restart
|
||||
/// (#419). The web UI's `/login` POST handler clears it via
|
||||
/// `clear_needs_login_sentinel` once the operator re-auths.
|
||||
/// - `"online"` clears both sentinels — the agent is healthy again.
|
||||
pub fn emit_status(&self, status: impl Into<String>) {
|
||||
let status = status.into();
|
||||
let rate_limited_path = crate::paths::state_dir().join("hyperhive-rate-limited");
|
||||
let needs_login_path = crate::paths::state_dir().join("hyperhive-needs-login");
|
||||
if status == "rate_limited" {
|
||||
self.rate_limited.store(true, Ordering::Relaxed);
|
||||
let _ = std::fs::write(&rate_limited_path, b"");
|
||||
} else {
|
||||
self.rate_limited.store(false, Ordering::Relaxed);
|
||||
let _ = std::fs::remove_file(&rate_limited_path);
|
||||
}
|
||||
if status == "needs_login_idle" {
|
||||
let _ = std::fs::write(&needs_login_path, b"");
|
||||
} else if status == "online" {
|
||||
// Re-auth completed (or manual flip back to online) — drop
|
||||
// the sentinel. `needs_login_in_progress` is a transient
|
||||
// mid-flow status and shouldn't clear yet.
|
||||
let _ = std::fs::remove_file(&needs_login_path);
|
||||
}
|
||||
self.emit(LiveEvent::StatusChanged { status });
|
||||
}
|
||||
|
||||
/// Returns true while the harness is parked after a rate-limit response.
|
||||
#[must_use]
|
||||
pub fn is_rate_limited(&self) -> bool {
|
||||
self.rate_limited.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Current state + since-when (unix seconds). Snapshot copy, no lock held.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn state_snapshot(&self) -> (TurnState, i64) {
|
||||
*self.state.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn emit(&self, event: LiveEvent) {
|
||||
if let Some(store) = &self.store
|
||||
&& let Err(e) = store.append(&event)
|
||||
{
|
||||
tracing::warn!(error = ?e, "events: append failed");
|
||||
}
|
||||
let envelope = BusEvent {
|
||||
seq: self.next_seq(),
|
||||
event,
|
||||
};
|
||||
// Lagged subscribers drop events — fine; the UI is a tail, not a log.
|
||||
let _ = self.tx.send(envelope);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<BusEvent> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
/// Most recent events, oldest first, capped at `HISTORY_CAPACITY`.
|
||||
/// Drives the terminal pre-fill when the operator opens the agent
|
||||
/// page; without a store (db open failed) this is empty.
|
||||
#[must_use]
|
||||
pub fn history(&self) -> Vec<LiveEvent> {
|
||||
let Some(store) = &self.store else {
|
||||
return Vec::new();
|
||||
};
|
||||
store.recent(HISTORY_CAPACITY).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Bus {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
775
hive-ag3nt/src/forge_notify.rs
Normal file
775
hive-ag3nt/src/forge_notify.rs
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
//! Background Forgejo notification poller.
|
||||
//!
|
||||
//! Reads `HIVE_FORGE_URL` + `{HYPERHIVE_STATE_DIR}/forge-token`, polls
|
||||
//! `GET /notifications?all=false` every 30 seconds, and delivers each
|
||||
//! unread notification as a broker `Wake { from: "forge" }` message so
|
||||
//! claude's normal turn loop picks it up.
|
||||
//!
|
||||
//! Each notification is enriched with the subject body and/or latest
|
||||
//! comment body so the agent sees actual content, not just a title.
|
||||
//!
|
||||
//! Graceful no-ops:
|
||||
//! - `HIVE_FORGE_URL` not set → disabled (no forge configured)
|
||||
//! - token file absent → disabled (agent has no forge account yet)
|
||||
//! - HTTP errors → logged at debug, retry next tick
|
||||
//!
|
||||
//! After successfully delivering a notification it is marked read via
|
||||
//! `PATCH /notifications/threads/{id}` so it does not re-fire. If delivery
|
||||
//! fails the thread is left unread so it resurfaces next tick.
|
||||
//!
|
||||
//! Self-notification filtering (closes #230):
|
||||
//! - New issues/PRs created by this agent (`reason == "author"` + `state == open`)
|
||||
//! are silently marked read — the agent already knows it opened them.
|
||||
//! - Comment notifications where the comment author matches this agent's own
|
||||
//! forge login are silently marked read.
|
||||
//!
|
||||
//! Own login is fetched once at startup via `GET /user` and cached for the
|
||||
//! lifetime of the polling loop.
|
||||
//!
|
||||
//! PR review formatting (closes #231):
|
||||
//! - When `latest_comment_url` points to a review (the fetched JSON has a
|
||||
//! `state` field like `APPROVED` / `REQUEST_CHANGES` / `COMMENT`), the
|
||||
//! notification is formatted as `[PR approved #N repo]` instead of the
|
||||
//! generic `[comment on PR #N repo]` so agents can action it immediately.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const POLL_INTERVAL_SECS: u64 = 30;
|
||||
const HTTP_TIMEOUT_SECS: u64 = 10;
|
||||
/// Maximum characters of a body/comment to include in the wake message.
|
||||
const BODY_TRUNCATE: usize = 500;
|
||||
|
||||
/// Spawn point: called once from `hive-ag3nt serve` (agent) or
|
||||
/// `hive-m1nd serve` (manager). Returns immediately if the forge is not
|
||||
/// configured. Otherwise loops forever, polling every
|
||||
/// `POLL_INTERVAL_SECS` seconds. Errors are never fatal.
|
||||
///
|
||||
/// `is_manager`: when true, wakes the inbox via `ManagerRequest::Wake`
|
||||
/// instead of `AgentRequest::Wake` (the manager socket rejects the agent
|
||||
/// request type).
|
||||
pub async fn run(socket: PathBuf, is_manager: bool) {
|
||||
let forge_url = match std::env::var("HIVE_FORGE_URL") {
|
||||
Ok(u) if !u.is_empty() => u,
|
||||
_ => {
|
||||
debug!("forge_notify: HIVE_FORGE_URL not set — disabled");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let state_dir = std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default();
|
||||
let token_path = format!("{state_dir}/forge-token");
|
||||
let token = match tokio::fs::read_to_string(&token_path).await {
|
||||
Ok(t) => {
|
||||
let t = t.trim().to_owned();
|
||||
if t.is_empty() {
|
||||
debug!("forge_notify: empty forge token at {token_path} — disabled");
|
||||
return;
|
||||
}
|
||||
t
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("forge_notify: no forge token at {token_path} ({e}) — disabled");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("forge_notify: failed to build HTTP client: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch own login once for self-notification filtering (closes #230).
|
||||
// Falls back to empty string on failure — no filtering (safe degradation).
|
||||
let own_login = {
|
||||
let url = format!("{forge_url}/api/v1/user");
|
||||
fetch_json(&client, &url, &token)
|
||||
.await
|
||||
.and_then(|v| v["login"].as_str().map(std::borrow::ToOwned::to_owned))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if own_login.is_empty() {
|
||||
warn!("forge_notify: could not resolve own login — self-notification filtering disabled");
|
||||
} else {
|
||||
debug!(%own_login, "forge_notify: own login resolved");
|
||||
}
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(POLL_INTERVAL_SECS));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
// First tick fires immediately — skip it so we don't race the broker
|
||||
// socket becoming available right at boot.
|
||||
interval.tick().await;
|
||||
|
||||
// HIVE_FORGE_KEEP_SUBSCRIPTIONS=1 disables auto-unsubscribe for agents
|
||||
// that intentionally consume the full repo notification firehose (e.g. triage).
|
||||
let keep_subscriptions = std::env::var("HIVE_FORGE_KEEP_SUBSCRIPTIONS")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Optional reason drop-list. `HIVE_FORGE_NOTIFY_SKIP_REASONS` is a
|
||||
// comma-separated list of Forgejo notification `reason` values to
|
||||
// suppress (e.g. `subscribed,participating`). Notifications with
|
||||
// those reasons are marked read and silently dropped; everything
|
||||
// else -- including notifications with a null/unrecognised reason --
|
||||
// is delivered. Drop-list is safer than an allow-list: it kills the
|
||||
// firehose without risking silent misses of directed signals
|
||||
// (review_requested, assigned) or future unknown reason strings.
|
||||
// Configurable per-agent via `hyperhive.forge.skipNotifyReasons` in agent.nix.
|
||||
let skip_reasons: Vec<String> = std::env::var("HIVE_FORGE_NOTIFY_SKIP_REASONS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
|
||||
if skip_reasons.is_empty() {
|
||||
info!(forge_url = %forge_url, "forge_notify: polling started (all reasons)");
|
||||
} else {
|
||||
info!(forge_url = %forge_url, skip = ?skip_reasons, "forge_notify: polling started");
|
||||
}
|
||||
|
||||
// Repos we have already unsubscribed this process lifetime. Persists
|
||||
// across polls so we don't hammer DELETE on every cycle.
|
||||
let mut unsubbed_repos: HashSet<String> = HashSet::new();
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
poll_once(
|
||||
&client,
|
||||
&forge_url,
|
||||
&token,
|
||||
&socket,
|
||||
is_manager,
|
||||
keep_subscriptions,
|
||||
&mut unsubbed_repos,
|
||||
&own_login,
|
||||
&skip_reasons,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a JSON value from a URL using the agent's forge token. Returns
|
||||
/// `None` on any HTTP or parse error (best-effort enrichment).
|
||||
async fn fetch_json(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
token: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
resp.json().await.ok()
|
||||
}
|
||||
|
||||
/// Map a Forgejo notification `subject.type` to a human-readable label.
|
||||
/// Known values: "Pull", "Issue", "Commit", "Repository". Any unknown
|
||||
/// type is passed through as-is so new Forgejo types degrade gracefully
|
||||
/// rather than silently collapsing into a generic label.
|
||||
fn notif_type_label(t: &str) -> &str {
|
||||
match t {
|
||||
"Pull" => "PR",
|
||||
"Issue" => "issue",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to `max` bytes at a char boundary, appending `…` if cut.
|
||||
/// Escape ATX-style markdown headings (`# h`, `## h`, …) in a
|
||||
/// comment/review body before we embed it inline in the forge-notify
|
||||
/// wrapper. The wrapper is the markdown context the dashboard's
|
||||
/// `marked.parse` sees; without this, a body line like `## argus
|
||||
/// review` blows into a top-level h2 in the agent's chat row,
|
||||
/// dwarfing the rest of the wrapper text (closes #455).
|
||||
///
|
||||
/// Backslash before `#` is the standard markdown escape — `\#` renders
|
||||
/// as the literal character `#`, so the line is preserved verbatim
|
||||
/// without claiming heading-level styling. Indented lines keep their
|
||||
/// indentation. Lines that don't start with `#` (ignoring leading
|
||||
/// whitespace) are passed through unchanged. Setext-style headings
|
||||
/// (`heading\n===`) are not handled here — rarer in practice and
|
||||
/// would need multi-line lookahead; revisit if it actually shows up.
|
||||
///
|
||||
/// **ATX shape strictly:** CommonMark requires a space (or end-of-line)
|
||||
/// after the 1-6 leading `#`s to count as an ATX heading. Lines like
|
||||
/// `#tag`, `#123`, `#!/bin/bash` are NOT headings — passing them through
|
||||
/// untouched avoids the cosmetic noise argus flagged on PR #518 (`\#tag`
|
||||
/// renders the same as `#tag`, but the escape is unnecessary).
|
||||
///
|
||||
/// **Trailing newline preserved:** `split_inclusive('\n')` keeps each
|
||||
/// line's terminator so the join round-trips a body that ended in `\n`.
|
||||
fn escape_md_headings(body: &str) -> String {
|
||||
let mut out = String::with_capacity(body.len());
|
||||
for line in body.split_inclusive('\n') {
|
||||
let (content, terminator) = match line.strip_suffix('\n') {
|
||||
Some(rest) => (rest, "\n"),
|
||||
None => (line, ""),
|
||||
};
|
||||
let trimmed = content.trim_start();
|
||||
if is_atx_heading(trimmed) {
|
||||
let lead = &content[..content.len() - trimmed.len()];
|
||||
out.push_str(lead);
|
||||
out.push('\\');
|
||||
out.push_str(trimmed);
|
||||
} else {
|
||||
out.push_str(content);
|
||||
}
|
||||
out.push_str(terminator);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Strict CommonMark ATX-heading detector: 1-6 leading `#`s followed
|
||||
/// by either a space, tab, or end-of-line. Anything tighter (`#tag`,
|
||||
/// `#123`) is a non-heading line that the renderer will not promote.
|
||||
fn is_atx_heading(line: &str) -> bool {
|
||||
let hashes = line.bytes().take_while(|&b| b == b'#').count();
|
||||
if !(1..=6).contains(&hashes) {
|
||||
return false;
|
||||
}
|
||||
match line.as_bytes().get(hashes) {
|
||||
None => true, // bare `#` / `##` / ... on its own line
|
||||
Some(b' ') | Some(b'\t') => true, // proper ATX with space/tab after #s
|
||||
_ => false, // `#tag` / `#123` — not a heading
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_owned();
|
||||
}
|
||||
let end = s
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.take_while(|&i| i <= max - 3)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
format!("{}…", &s[..end])
|
||||
}
|
||||
|
||||
/// Map a Forgejo review state to a readable action label.
|
||||
/// Returns `None` for non-review states (regular comments have no `state` field;
|
||||
/// `PENDING` means the review was saved but not submitted yet).
|
||||
/// Forgejo review states: "APPROVED", "`REQUEST_CHANGES`", "COMMENT", "PENDING".
|
||||
fn review_state_label(state: &str) -> Option<&str> {
|
||||
match state {
|
||||
"APPROVED" => Some("approved"),
|
||||
"REQUEST_CHANGES" => Some("changes requested"),
|
||||
"COMMENT" => Some("review comment"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a human-readable wake message for one Forgejo notification.
|
||||
/// Returns `None` when the notification is a self-echo (actor is `own_login`)
|
||||
/// and should be silently discarded (and marked read by the caller).
|
||||
///
|
||||
/// Formats:
|
||||
/// - Comment: `[comment on PR #N repo] title\nurl: ...\n\nauthor: body\nassignee: user\nreason: mention`
|
||||
/// - Review: `[PR approved #N repo] title\nurl: ...\n\nreviewer: body\nassignee: user\nreason: review_requested`
|
||||
/// - New item: `[new issue #N repo] title\nurl: ...\nassignee: user\nreason: author`
|
||||
/// - State: `[PR merged #N repo] title\nurl: ...\nassignee: user\nreason: subscribed`
|
||||
///
|
||||
/// Assignees (and, for PRs, `requested_reviewers`) are appended unconditionally
|
||||
/// on all issue/PR notifications (closes #256).
|
||||
///
|
||||
/// The `reason` field from the Forgejo notification is always appended (closes #110).
|
||||
/// Forgejo emits one notification entry per reason for the same event, so including
|
||||
/// it makes otherwise-identical messages distinguishable (e.g. `mention` vs
|
||||
/// `subscribed` both arriving for the same PR comment).
|
||||
///
|
||||
/// Number is extracted from `html_url` last path segment before any `#`.
|
||||
/// Repo slug (`owner/name`) is always included — agents may watch multiple repos.
|
||||
async fn format_notification(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
notif: &serde_json::Value,
|
||||
own_login: &str,
|
||||
) -> Option<String> {
|
||||
let title = notif["subject"]["title"].as_str().unwrap_or("?");
|
||||
let notif_type = notif["subject"]["type"].as_str().unwrap_or("?");
|
||||
let html_url = notif["subject"]["html_url"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| notif["subject"]["url"].as_str().unwrap_or(""));
|
||||
|
||||
// Extract issue/PR number from the html_url. URL ends with /issues/N or
|
||||
// /pulls/N (possibly followed by #anchor for comments). Best-effort.
|
||||
let num = html_url
|
||||
.split('#')
|
||||
.next()
|
||||
.and_then(|u| u.rsplit('/').next())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(|n| format!(" #{n}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Repo slug for multi-repo disambiguation. Falls back gracefully when absent.
|
||||
let repo = notif["repository"]["full_name"]
|
||||
.as_str()
|
||||
.map(|r| format!(" {r}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
// API URLs for fetching content
|
||||
let subject_api_url = notif["subject"]["url"].as_str().unwrap_or("");
|
||||
let comment_api_url = notif["subject"]["latest_comment_url"].as_str().unwrap_or("");
|
||||
let comment_html_url = notif["subject"]["latest_comment_html_url"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
|
||||
// Always fetch subject detail for assignee/reviewer metadata (#256).
|
||||
// Keeps agents informed of current ownership without a follow-up fetch.
|
||||
let subject = if subject_api_url.is_empty() {
|
||||
None
|
||||
} else {
|
||||
fetch_json(client, subject_api_url, token).await
|
||||
};
|
||||
|
||||
let is_pr = matches!(notif_type, "Pull Request" | "Pull");
|
||||
let reason = notif["reason"].as_str().unwrap_or("");
|
||||
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr, reason);
|
||||
|
||||
// Determine whether this notification was triggered by a comment/review or
|
||||
// by creation/state-change of the subject itself.
|
||||
let has_comment = !comment_api_url.is_empty() && comment_api_url != subject_api_url;
|
||||
|
||||
let meta = NotifMeta { title, notif_type, html_url, num, repo, meta_suffix, reason, subject, is_pr };
|
||||
if has_comment {
|
||||
format_comment_notification(client, token, &meta, comment_api_url, comment_html_url, own_login).await
|
||||
} else {
|
||||
format_state_change_notification(notif, &meta, own_login)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared notification metadata extracted from the raw Forgejo JSON.
|
||||
struct NotifMeta<'a> {
|
||||
title: &'a str,
|
||||
notif_type: &'a str,
|
||||
html_url: &'a str,
|
||||
num: String,
|
||||
repo: String,
|
||||
meta_suffix: String,
|
||||
/// Forgejo `reason` value (e.g. "mention", "assigned", "subscribed").
|
||||
/// Appended to every formatted message so that multiple notifications for
|
||||
/// the same event (each with a different reason) are distinguishable (closes #110).
|
||||
reason: &'a str,
|
||||
/// Fetched subject detail (issue/PR JSON); used for review-request detection.
|
||||
subject: Option<serde_json::Value>,
|
||||
is_pr: bool,
|
||||
}
|
||||
|
||||
/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...` and `\nreason: ...`) suffix
|
||||
/// appended to all notification kinds.
|
||||
fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: &str) -> String {
|
||||
let assignees: Vec<&str> = subject
|
||||
.and_then(|s| s["assignees"].as_array())
|
||||
.map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
let assignee_line = if assignees.is_empty() {
|
||||
"assignee: unassigned".to_owned()
|
||||
} else {
|
||||
format!("assignee: {}", assignees.join(", "))
|
||||
};
|
||||
// For PRs, include requested_reviewers when present.
|
||||
let reviewer_line = if is_pr {
|
||||
let reviewers: Vec<&str> = subject
|
||||
.and_then(|s| s["requested_reviewers"].as_array())
|
||||
.map(|arr| arr.iter().filter_map(|r| r["login"].as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
if reviewers.is_empty() { None } else { Some(format!("reviewer: {}", reviewers.join(", "))) }
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Always include reason so multiple notifications for the same event
|
||||
// (each with a different Forgejo reason) are distinguishable (closes #110).
|
||||
let reason_line = if reason.is_empty() { None } else { Some(format!("reason: {reason}")) };
|
||||
let mut out = format!("\n{assignee_line}");
|
||||
if let Some(r) = reviewer_line { write!(out, "\n{r}").ok(); }
|
||||
if let Some(r) = reason_line { write!(out, "\n{r}").ok(); }
|
||||
out
|
||||
}
|
||||
|
||||
/// Format a notification triggered by a new comment or review submission.
|
||||
async fn format_comment_notification(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
meta: &NotifMeta<'_>,
|
||||
comment_api_url: &str,
|
||||
comment_html_url: &str,
|
||||
own_login: &str,
|
||||
) -> Option<String> {
|
||||
let payload = fetch_json(client, comment_api_url, token).await;
|
||||
|
||||
let actor_login = payload
|
||||
.as_ref()
|
||||
.and_then(|c| c["user"]["login"].as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Self-notification filter (#230): skip if we authored the comment/review.
|
||||
if !own_login.is_empty() && actor_login == own_login {
|
||||
debug!(%own_login, "forge_notify: skipping self-authored comment/review");
|
||||
return None;
|
||||
}
|
||||
|
||||
let body_text = payload
|
||||
.as_ref()
|
||||
.and_then(|c| c["body"].as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
// PR review detection (#231): Forgejo review objects carry a `state` field
|
||||
// with values like "APPROVED" / "REQUEST_CHANGES" / "COMMENT". Regular
|
||||
// issue/PR comments have no such field. Format reviews distinctly so the
|
||||
// agent knows the review outcome immediately without reading the body.
|
||||
let review_state = payload
|
||||
.as_ref()
|
||||
.and_then(|c| c["state"].as_str())
|
||||
.and_then(review_state_label);
|
||||
|
||||
let url = if comment_html_url.is_empty() { meta.html_url } else { comment_html_url };
|
||||
let author = if actor_login.is_empty() { "?" } else { actor_login };
|
||||
let NotifMeta { title, notif_type, num, repo, meta_suffix, .. } = meta;
|
||||
|
||||
// Escape ATX headings in the user-authored body so the embedded
|
||||
// text doesn't blow into top-level h1/h2 in the wrapper message
|
||||
// when the dashboard renders it (closes #455). Done once here
|
||||
// because both code paths fall through the same truncate+embed
|
||||
// pattern.
|
||||
let escaped = escape_md_headings(body_text);
|
||||
let body_for_embed = truncate(&escaped, BODY_TRUNCATE);
|
||||
if let Some(review_label) = review_state {
|
||||
// Review submission on a PR.
|
||||
let kind = format!("PR {review_label}{num}{repo}");
|
||||
let mut out = format!("[{kind}] {title}\nurl: {url}");
|
||||
if body_text.is_empty() {
|
||||
write!(out, "\n\nreviewer: {author}").ok();
|
||||
} else {
|
||||
write!(out, "\n\n{author}: {body_for_embed}").ok();
|
||||
}
|
||||
out.push_str(meta_suffix);
|
||||
Some(out)
|
||||
} else {
|
||||
// Regular comment.
|
||||
let kind = format!("comment on {}{num}{repo}", notif_type_label(notif_type));
|
||||
let mut out = format!("[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}");
|
||||
if out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
out.push_str(meta_suffix);
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a notification triggered by creation or state change of the subject.
|
||||
fn format_state_change_notification(
|
||||
notif: &serde_json::Value,
|
||||
meta: &NotifMeta<'_>,
|
||||
own_login: &str,
|
||||
) -> Option<String> {
|
||||
// Classification uses notif["subject"]["state"] directly — Forgejo
|
||||
// returns "open" / "closed" / "merged" here. We do NOT rely on
|
||||
// fetching the PR/issue detail for `merged`:
|
||||
// - `subject.url` points to the *issues* endpoint, which returns
|
||||
// `pull_request.merged`, not top-level `merged`.
|
||||
// - Forgejo API type is "Pull" / "Issue", never "Pull Request".
|
||||
let notif_state = notif["subject"]["state"].as_str().unwrap_or("");
|
||||
|
||||
// Self-notification filter (#230): skip new items we authored ourselves.
|
||||
// `reason == "author"` combined with open state means we just opened the
|
||||
// issue/PR. We do NOT filter merged/closed state changes — those are
|
||||
// triggered by someone else and we want them.
|
||||
let is_new = notif_state == "open" || notif_state.is_empty();
|
||||
if is_new && meta.reason == "author" && !own_login.is_empty() {
|
||||
debug!(%own_login, "forge_notify: skipping self-authored new item");
|
||||
return None;
|
||||
}
|
||||
|
||||
let NotifMeta { title, notif_type, html_url, num, repo, meta_suffix, reason: _, subject, is_pr } = meta;
|
||||
let label = notif_type_label(notif_type);
|
||||
let kind = match notif_state {
|
||||
"merged" => format!("{label} merged{num}{repo}"),
|
||||
"closed" => format!("{label} closed{num}{repo}"),
|
||||
"open" | "" => format!("new {label}{num}{repo}"),
|
||||
other => format!("{label}{num}{repo}: {other}"),
|
||||
};
|
||||
|
||||
// Review-request detection (#253): Forgejo does not always set
|
||||
// reason == "review_requested" (observed as null). Check
|
||||
// requested_reviewers instead, which is reliable. If own_login is
|
||||
// in the list, override the kind.
|
||||
// subject and is_pr are already fetched unconditionally above (#256).
|
||||
let is_review_request = is_new
|
||||
&& *is_pr
|
||||
&& !own_login.is_empty()
|
||||
&& subject
|
||||
.as_ref()
|
||||
.and_then(|s| s["requested_reviewers"].as_array())
|
||||
.is_some_and(|arr| arr.iter().any(|r| r["login"].as_str() == Some(own_login)));
|
||||
let kind = if is_review_request {
|
||||
format!("review requested{num}{repo}")
|
||||
} else {
|
||||
kind
|
||||
};
|
||||
|
||||
let mut out = format!("[{kind}] {title}\nurl: {html_url}");
|
||||
out.push_str(meta_suffix);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn poll_once(
|
||||
client: &reqwest::Client,
|
||||
forge_url: &str,
|
||||
token: &str,
|
||||
socket: &Path,
|
||||
is_manager: bool,
|
||||
keep_subscriptions: bool,
|
||||
unsubbed_repos: &mut HashSet<String>,
|
||||
own_login: &str,
|
||||
skip_reasons: &[String],
|
||||
) {
|
||||
let url = format!("{forge_url}/api/v1/notifications?all=false&limit=50");
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
debug!("forge_notify: poll request failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
debug!("forge_notify: poll status {}", resp.status());
|
||||
return;
|
||||
}
|
||||
|
||||
let notifications: Vec<serde_json::Value> = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("forge_notify: response parse error: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if notifications.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
debug!(count = notifications.len(), "forge_notify: delivering notifications");
|
||||
|
||||
for notif in ¬ifications {
|
||||
let Some(id) = notif["id"].as_u64() else { continue };
|
||||
|
||||
// Reason drop-list: suppress noisy reasons (subscribed/participating).
|
||||
// null/unknown reasons pass through — directed signals are never
|
||||
// silently dropped even if Forgejo returns an unexpected value.
|
||||
if !skip_reasons.is_empty() {
|
||||
let reason = notif["reason"].as_str().unwrap_or("");
|
||||
if !reason.is_empty() && skip_reasons.iter().any(|r| r == reason) {
|
||||
debug!(%id, %reason, "forge_notify: skipping (reason in drop-list)");
|
||||
mark_read(client, forge_url, token, id).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let body_opt = format_notification(client, token, notif, own_login).await;
|
||||
|
||||
// None means self-echo — mark read silently, no delivery.
|
||||
let Some(body) = body_opt else {
|
||||
mark_read(client, forge_url, token, id).await;
|
||||
continue;
|
||||
};
|
||||
|
||||
let delivered = if is_manager {
|
||||
let req = hive_sh4re::ManagerRequest::Wake {
|
||||
from: "forge".to_owned(),
|
||||
body,
|
||||
};
|
||||
crate::client::request::<_, hive_sh4re::ManagerResponse>(socket, &req)
|
||||
.await
|
||||
.map(|_| ())
|
||||
} else {
|
||||
let req = hive_sh4re::AgentRequest::Wake {
|
||||
from: "forge".to_owned(),
|
||||
body,
|
||||
};
|
||||
crate::client::request::<_, hive_sh4re::AgentResponse>(socket, &req)
|
||||
.await
|
||||
.map(|_| ())
|
||||
};
|
||||
match delivered {
|
||||
Ok(()) => {
|
||||
debug!(%id, "forge_notify: delivered");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(%id, error = ?e, "forge_notify: deliver failed — leaving unread");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as read only after successful delivery so a failed-delivery
|
||||
// notification resurfaces on the next poll tick.
|
||||
mark_read(client, forge_url, token, id).await;
|
||||
|
||||
// Auto-unsubscribe from broad repo watches when the notification
|
||||
// reason is "subscribed" (agent watching the whole repo). Skipped
|
||||
// when HIVE_FORGE_KEEP_SUBSCRIPTIONS=1 — triage and other firehose
|
||||
// consumers set this to retain broad repo visibility.
|
||||
let reason = notif["reason"].as_str().unwrap_or("");
|
||||
if !keep_subscriptions && reason == "subscribed"
|
||||
&& let Some(repo) = notif["repository"]["full_name"].as_str()
|
||||
&& !unsubbed_repos.contains(repo) {
|
||||
let unsub_url = format!("{forge_url}/api/v1/repos/{repo}/subscription");
|
||||
match client
|
||||
.delete(&unsub_url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() || r.status().as_u16() == 404 => {
|
||||
debug!(%repo, "forge_notify: unsubscribed from repo watch");
|
||||
unsubbed_repos.insert(repo.to_owned());
|
||||
}
|
||||
Ok(r) => {
|
||||
debug!(%repo, status = %r.status(), "forge_notify: unsub non-2xx (ignored)");
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(%repo, error = ?e, "forge_notify: unsub request failed (ignored)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a notification thread as read. Best-effort — logs on failure but
|
||||
/// does not abort the poll loop. A notification left unread will resurface
|
||||
/// on the next poll tick (desirable for delivery failures; for self-echo
|
||||
/// silencing we call this without prior delivery).
|
||||
async fn mark_read(client: &reqwest::Client, forge_url: &str, token: &str, id: u64) {
|
||||
let mark_url = format!("{forge_url}/api/v1/notifications/threads/{id}");
|
||||
match client
|
||||
.patch(&mark_url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Err(e) => {
|
||||
warn!(%id, error = ?e, "forge_notify: mark-read request failed — notification will resurface");
|
||||
}
|
||||
Ok(r) if !r.status().is_success() => {
|
||||
warn!(%id, status = %r.status(), "forge_notify: mark-read returned non-2xx — notification will resurface");
|
||||
}
|
||||
Ok(_) => {
|
||||
debug!(%id, "forge_notify: marked read");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn escape_md_headings_escapes_top_level_atx() {
|
||||
// The #455 repro: argus reviews start with `## argus review`,
|
||||
// which would otherwise become an h2 in the wrapper message.
|
||||
assert_eq!(
|
||||
escape_md_headings("## argus review\n\nlgtm."),
|
||||
"\\## argus review\n\nlgtm.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_md_headings_escapes_all_heading_depths() {
|
||||
let body = "# h1\n## h2\n### h3\n###### h6\nbody";
|
||||
assert_eq!(
|
||||
escape_md_headings(body),
|
||||
"\\# h1\n\\## h2\n\\### h3\n\\###### h6\nbody",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_md_headings_preserves_indent() {
|
||||
// Indented "headings" inside lists / nested quotes keep
|
||||
// their leading whitespace so structure isn't visually
|
||||
// collapsed by the escape.
|
||||
assert_eq!(
|
||||
escape_md_headings(" ## indented\nbody"),
|
||||
" \\## indented\nbody",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_md_headings_passes_non_heading_lines_through() {
|
||||
let body = "plain text\nwith a #hashtag in middle\n```\n# in fenced code\n```";
|
||||
let escaped = escape_md_headings(body);
|
||||
// Lines without leading `#` are untouched. The `# in fenced
|
||||
// code` line still gets escaped (we don't track fenced-code
|
||||
// state) — acceptable: inside a fenced block the escape is
|
||||
// visually inert anyway because the renderer treats the
|
||||
// content as literal.
|
||||
assert!(escaped.contains("plain text"));
|
||||
assert!(escaped.contains("with a #hashtag in middle"));
|
||||
assert!(escaped.contains("\\# in fenced code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_md_headings_handles_empty_and_whitespace_only() {
|
||||
assert_eq!(escape_md_headings(""), "");
|
||||
assert_eq!(escape_md_headings(" "), " ");
|
||||
assert_eq!(escape_md_headings("\n\n"), "\n\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_md_headings_skips_non_atx_hash_lines() {
|
||||
// ATX requires a space after the `#`s. Lines like `#tag`,
|
||||
// `#123`, `#!/bin/bash` are NOT headings — argus's PR #518
|
||||
// yellow nit: don't add cosmetic noise where the renderer
|
||||
// wouldn't promote the line in the first place.
|
||||
let body = "#tag\n#123\n#!/bin/bash\n####### too many hashes\nbody";
|
||||
let escaped = escape_md_headings(body);
|
||||
// All four leading `#` lines pass through untouched: too few
|
||||
// (still need space), seven `#`s (over the cap), shebang
|
||||
// (no space).
|
||||
assert_eq!(escaped, body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_md_headings_handles_bare_hash_lines() {
|
||||
// `#` alone on a line IS a valid ATX (h1 with empty text) per
|
||||
// CommonMark; escape it to match the renderer's behaviour.
|
||||
assert_eq!(escape_md_headings("#"), "\\#");
|
||||
assert_eq!(escape_md_headings("##"), "\\##");
|
||||
assert_eq!(escape_md_headings("###"), "\\###");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_md_headings_preserves_trailing_newline() {
|
||||
// `split_inclusive('\n')` round-trips a body ending in a
|
||||
// newline. Important for embedded forge-notify bodies whose
|
||||
// source already terminates with `\n` — the wrapper's spacing
|
||||
// otherwise gets eaten.
|
||||
assert_eq!(escape_md_headings("## h\n"), "\\## h\n");
|
||||
assert_eq!(escape_md_headings("body\n"), "body\n");
|
||||
assert_eq!(escape_md_headings("no trailing"), "no trailing");
|
||||
}
|
||||
}
|
||||
22
hive-ag3nt/src/lib.rs
Normal file
22
hive-ag3nt/src/lib.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//! Shared in-container harness code used by both `hive-ag3nt` (agent) and
|
||||
//! `hive-m1nd` (manager) binaries.
|
||||
|
||||
pub mod client;
|
||||
pub mod events;
|
||||
pub mod forge_notify;
|
||||
pub mod login;
|
||||
pub mod login_session;
|
||||
pub mod mcp;
|
||||
pub mod paths;
|
||||
pub mod plugins;
|
||||
pub mod serve_common;
|
||||
pub mod stats;
|
||||
pub mod turn;
|
||||
pub mod turn_stats;
|
||||
pub mod web_ui;
|
||||
|
||||
/// Default socket path inside the container — bind-mounted by `hive-c0re`.
|
||||
pub const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock";
|
||||
|
||||
/// Default web UI port — used when `HIVE_PORT` env is unset.
|
||||
pub const DEFAULT_WEB_PORT: u16 = 8042;
|
||||
56
hive-ag3nt/src/login.rs
Normal file
56
hive-ag3nt/src/login.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
//! Login-state probe for the bind-mounted `~/.claude/` dir. The dir is
|
||||
//! provided by hive-c0re and persists across container destroy/recreate so
|
||||
//! OAuth tokens survive.
|
||||
//!
|
||||
//! "Has session" today means "the dir contains at least one regular file."
|
||||
//! That's a heuristic: a fresh bind-mount starts empty, and `claude auth login`
|
||||
//! writes credentials into the dir. We may refine later (probe for the
|
||||
//! specific credentials filename, or run a no-op `claude` call) once the
|
||||
//! exact layout is locked in.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Returns the Claude credentials directory for this agent, derived from
|
||||
/// `HIVE_LABEL`. Manager ("hm1nd") uses `/root/.claude`; sub-agents use
|
||||
/// `/agents/{label}/claude`. Overridable via `HYPERHIVE_CLAUDE_DIR`.
|
||||
#[must_use]
|
||||
pub fn default_dir() -> PathBuf {
|
||||
crate::paths::claude_dir()
|
||||
}
|
||||
|
||||
/// Returns `true` if `dir` exists and contains any regular file. Used at
|
||||
/// startup to decide whether to enter the turn loop (logged in) or stay in
|
||||
/// the partial-run "needs login" state.
|
||||
#[must_use]
|
||||
pub fn has_session(dir: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().is_ok_and(|t| t.is_file()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Login state the harness reports to its web UI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoginState {
|
||||
/// `~/.claude/` has credentials; turn loop is running.
|
||||
Online,
|
||||
/// `~/.claude/` is empty; harness is up, web UI is bound, turn loop is NOT
|
||||
/// running. Operator needs to complete login from the web UI.
|
||||
NeedsLogin,
|
||||
}
|
||||
|
||||
impl LoginState {
|
||||
#[must_use]
|
||||
pub fn from_dir(dir: &Path) -> Self {
|
||||
if has_session(dir) {
|
||||
Self::Online
|
||||
} else {
|
||||
Self::NeedsLogin
|
||||
}
|
||||
}
|
||||
}
|
||||
302
hive-ag3nt/src/login_session.rs
Normal file
302
hive-ag3nt/src/login_session.rs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
//! `claude auth login` driver. Spawns the login command under plain stdio pipes,
|
||||
//! accumulates stdout+stderr in a shared buffer (so the web UI can show
|
||||
//! whatever URL/prompt claude emits), and writes paste-back codes from the
|
||||
//! UI into the child's stdin.
|
||||
//!
|
||||
//! No PTY — we're betting `claude` produces a parseable URL on stdout and
|
||||
//! accepts a code on stdin even when not on a terminal. If it refuses or
|
||||
//! garbles, we'll redo this module backed by `portable-pty` (see PLAN.md
|
||||
//! Phase 8).
|
||||
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
|
||||
const DEFAULT_CMD: &str = "claude";
|
||||
const DEFAULT_ARGS: &[&str] = &["auth", "login"];
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// Concatenated stdout+stderr as it streams from the child.
|
||||
output: String,
|
||||
/// First URL-looking substring we saw in the output. Surface this on the
|
||||
/// web UI as the link the operator should open.
|
||||
url: Option<String>,
|
||||
/// Set when the child has exited. The web UI uses this to know whether
|
||||
/// the operator can still paste a code.
|
||||
finished: bool,
|
||||
/// Exit status note (e.g. "exited with code 0", "killed by signal 15"),
|
||||
/// shown next to a "finished" badge once the child returns.
|
||||
exit_note: Option<String>,
|
||||
}
|
||||
|
||||
/// A running `claude auth login` subprocess.
|
||||
pub struct LoginSession {
|
||||
child: Mutex<Child>,
|
||||
/// Tokio mutex because we hold the guard across the `write_all().await`
|
||||
/// in `submit_code`. The other locks are blocking-only and stay on
|
||||
/// `std::sync::Mutex`.
|
||||
stdin: tokio::sync::Mutex<Option<ChildStdin>>,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
impl LoginSession {
|
||||
/// Spawn the login command. The exact binary/args are configurable via
|
||||
/// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by
|
||||
/// default we run `claude auth login`. Failing to spawn returns an error
|
||||
/// before any state is registered.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if spawning the login command fails, or if the child's
|
||||
/// stdio handles cannot be acquired.
|
||||
pub fn start() -> Result<Self> {
|
||||
let (cmd, args) = resolve_command();
|
||||
tracing::info!(%cmd, ?args, "spawning login session");
|
||||
|
||||
let mut child = Command::new(&cmd)
|
||||
.args(&args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
// `claude` reads $HOME for the credentials dir; the bind-mount
|
||||
// puts it at /root/.claude, which is already the default home
|
||||
// for uid 0 inside the container. Nothing extra to set here.
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.with_context(|| format!("spawn `{cmd}`"))?;
|
||||
|
||||
let stdin = child.stdin.take().context("child stdin")?;
|
||||
let stdout = child.stdout.take().context("child stdout")?;
|
||||
let stderr = child.stderr.take().context("child stderr")?;
|
||||
|
||||
let state = Arc::new(Mutex::new(State::default()));
|
||||
tokio::spawn(pump(BufReader::new(stdout), state.clone(), "stdout"));
|
||||
tokio::spawn(pump(BufReader::new(stderr), state.clone(), "stderr"));
|
||||
|
||||
Ok(Self {
|
||||
child: Mutex::new(child),
|
||||
stdin: tokio::sync::Mutex::new(Some(stdin)),
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `code` (plus a newline) to the child's stdin. Returns an error
|
||||
/// if the stdin has already been closed (e.g. after the child exited or
|
||||
/// after a prior submission consumed it).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the login stdin is already closed, or if writing
|
||||
/// to or flushing the stdin pipe fails.
|
||||
pub async fn submit_code(&self, code: &str) -> Result<()> {
|
||||
let mut guard = self.stdin.lock().await;
|
||||
let stdin = guard.as_mut().context("login stdin already closed")?;
|
||||
let line = format!("{}\n", code.trim());
|
||||
stdin
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.context("write code to claude stdin")?;
|
||||
stdin.flush().await.context("flush claude stdin")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close stdin so claude sees EOF (useful if it's waiting for more input
|
||||
/// after the code submit).
|
||||
pub async fn close_stdin(&self) {
|
||||
let _ = self.stdin.lock().await.take();
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn output(&self) -> String {
|
||||
self.state.lock().unwrap().output.clone()
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn url(&self) -> Option<String> {
|
||||
self.state.lock().unwrap().url.clone()
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn finished(&self) -> bool {
|
||||
self.state.lock().unwrap().finished
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn exit_note(&self) -> Option<String> {
|
||||
self.state.lock().unwrap().exit_note.clone()
|
||||
}
|
||||
|
||||
/// Best-effort: poll the child once and update `finished`/`exit_note`.
|
||||
/// Called by the web UI on each render so the state stays fresh without
|
||||
/// running a dedicated reaper task.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if an internal lock is poisoned.
|
||||
pub fn poll(&self) {
|
||||
let mut child = self.child.lock().unwrap();
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let mut s = self.state.lock().unwrap();
|
||||
s.finished = true;
|
||||
s.exit_note = Some(format!("{status}"));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let mut s = self.state.lock().unwrap();
|
||||
s.finished = true;
|
||||
s.exit_note = Some(format!("try_wait error: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill the child if it's still running. Idempotent.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn kill(&self) {
|
||||
if let Err(e) = self.child.lock().unwrap().start_kill() {
|
||||
tracing::warn!(error = ?e, "kill login child");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_command() -> (String, Vec<String>) {
|
||||
if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") {
|
||||
// Whitespace-only split — no quote handling. Fine for "claude auth login"
|
||||
// style overrides; if we need anything with embedded spaces we'll
|
||||
// switch to shell-words.
|
||||
let mut parts = raw.split_whitespace().map(str::to_owned);
|
||||
if let Some(cmd) = parts.next() {
|
||||
return (cmd, parts.collect());
|
||||
}
|
||||
}
|
||||
(
|
||||
DEFAULT_CMD.into(),
|
||||
DEFAULT_ARGS.iter().map(|s| (*s).to_owned()).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn pump<R: tokio::io::AsyncRead + Unpin>(
|
||||
mut reader: BufReader<R>,
|
||||
state: Arc<Mutex<State>>,
|
||||
tag: &'static str,
|
||||
) {
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
// read_line breaks on \n; for claude's TUI output that flushes by
|
||||
// line this is fine. If it ever blasts a single un-newlined blob,
|
||||
// we'll miss it until EOF (acceptable for the URL surface — claude
|
||||
// prints the URL on its own line).
|
||||
match reader.read_line(&mut buf).await {
|
||||
Ok(0) => {
|
||||
state.lock().unwrap().finished = true;
|
||||
break;
|
||||
}
|
||||
Ok(_) => {
|
||||
let mut s = state.lock().unwrap();
|
||||
if s.url.is_none()
|
||||
&& let Some(url) = extract_url(&buf)
|
||||
{
|
||||
tracing::info!(%url, %tag, "login URL detected");
|
||||
s.url = Some(url);
|
||||
}
|
||||
s.output.push_str(&buf);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, %tag, "login pump read error");
|
||||
let mut s = state.lock().unwrap();
|
||||
s.finished = true;
|
||||
s.exit_note = Some(format!("pump {tag} error: {e}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the first `https://…` substring on the line, terminating at any
|
||||
/// ASCII whitespace. Good enough for capturing claude's OAuth link without a
|
||||
/// regex dependency.
|
||||
fn extract_url(line: &str) -> Option<String> {
|
||||
let start = line.find("https://")?;
|
||||
let tail = &line[start..];
|
||||
let end = tail
|
||||
.find(|c: char| c.is_ascii_whitespace())
|
||||
.unwrap_or(tail.len());
|
||||
let url = tail[..end].trim_end_matches(['.', ',', ')', ']']);
|
||||
if url.len() > "https://".len() {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper used by the web UI to gate "is there a session running right now"
|
||||
/// without holding both this module's mutex and the `AppState`'s at once.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn drop_if_finished(slot: &Mutex<Option<Arc<LoginSession>>>) {
|
||||
let mut guard = slot.lock().unwrap();
|
||||
if let Some(s) = guard.as_ref() {
|
||||
s.poll();
|
||||
if s.finished() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LoginSession {
|
||||
fn drop(&mut self) {
|
||||
// kill_on_drop on the Command also ensures the child dies, but we
|
||||
// belt-and-brace it in case the runtime detaches.
|
||||
let _ = self.child.lock().unwrap().start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_url;
|
||||
|
||||
#[test]
|
||||
fn picks_first_https() {
|
||||
let line = " Go to https://claude.ai/oauth/abc?xyz=1 in your browser.\n";
|
||||
assert_eq!(
|
||||
extract_url(line).as_deref(),
|
||||
Some("https://claude.ai/oauth/abc?xyz=1"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_punctuation_stripped() {
|
||||
let line = "Open https://example.com/abc).\n";
|
||||
assert_eq!(
|
||||
extract_url(line).as_deref(),
|
||||
Some("https://example.com/abc"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_url() {
|
||||
assert_eq!(extract_url("nothing here\n"), None);
|
||||
}
|
||||
}
|
||||
1977
hive-ag3nt/src/mcp.rs
Normal file
1977
hive-ag3nt/src/mcp.rs
Normal file
File diff suppressed because it is too large
Load diff
32
hive-ag3nt/src/paths.rs
Normal file
32
hive-ag3nt/src/paths.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//! Per-agent path resolution for state and credential directories.
|
||||
//!
|
||||
//! All agents (including the manager "hm1nd") use `/agents/{label}/state`.
|
||||
//! Claude credentials are always at `/root/.claude` for all agents.
|
||||
//!
|
||||
//! Both paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`,
|
||||
//! `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Durable state directory for the current agent. Reads `HYPERHIVE_STATE_DIR`
|
||||
/// first (always set by the meta flake to `/agents/{label}/state`); falls back
|
||||
/// to the same pattern derived from `HIVE_LABEL` for dev/test environments
|
||||
/// where the env var may not be set.
|
||||
#[must_use]
|
||||
pub fn state_dir() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("HYPERHIVE_STATE_DIR") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
let label = std::env::var("HIVE_LABEL").unwrap_or_default();
|
||||
PathBuf::from(format!("/agents/{label}/state"))
|
||||
}
|
||||
|
||||
/// Claude credentials directory for the current agent. Always `/root/.claude`
|
||||
/// because the `claude` CLI reads `$HOME/.claude` (uid 0 → `/root`), and
|
||||
/// hive-c0re binds the per-agent credentials dir there for every container.
|
||||
/// Overridable via `HYPERHIVE_CLAUDE_DIR` for dev / test scenarios.
|
||||
#[must_use]
|
||||
pub fn claude_dir() -> PathBuf {
|
||||
std::env::var_os("HYPERHIVE_CLAUDE_DIR")
|
||||
.map_or_else(|| PathBuf::from("/root/.claude"), PathBuf::from)
|
||||
}
|
||||
184
hive-ag3nt/src/plugins.rs
Normal file
184
hive-ag3nt/src/plugins.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
//! Boot-time `claude plugin install` driver. Reads the list declared
|
||||
//! via the `hyperhive.claudePlugins` NixOS option (rendered to
|
||||
//! `/etc/hyperhive/claude-plugins.json` by the harness module) and
|
||||
//! shells out `claude plugin install <spec>` for each entry. Runs once
|
||||
//! per harness boot before the turn loop; `claude plugin install`
|
||||
//! is expected to be idempotent so reinstalling on each container
|
||||
//! recreate is fine. Failures log a warning but do not abort boot —
|
||||
//! we'd rather start without a plugin than refuse to serve.
|
||||
//!
|
||||
//! Before installing, all configured marketplaces are updated so that
|
||||
//! plugin specs resolve against current index data. Marketplace update
|
||||
//! failures are non-fatal — stale index is better than no install attempt.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::client;
|
||||
|
||||
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
|
||||
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
|
||||
const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json";
|
||||
|
||||
/// Add every marketplace from `/etc/hyperhive/claude-marketplaces.json`
|
||||
/// via `claude plugin marketplace add <source>`. Idempotent: re-add of
|
||||
/// an existing marketplace is treated as success (claude prints an
|
||||
/// "already exists" message and exits non-zero on some versions).
|
||||
/// Required before any `<plugin>@<marketplace>` install can resolve.
|
||||
async fn add_marketplaces() {
|
||||
let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else {
|
||||
return;
|
||||
};
|
||||
let sources: Vec<String> = match serde_json::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = MARKETPLACES_PATH, error = ?e, "claude-marketplaces spec parse failed; skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for source in sources {
|
||||
match Command::new("claude")
|
||||
.args(["plugin", "marketplace", "add", &source])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) if out.status.success() => {
|
||||
tracing::info!(source = %source, "claude plugin marketplace add ok");
|
||||
}
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
if stderr.contains("already") {
|
||||
tracing::debug!(source = %source, "marketplace already added");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
source = %source,
|
||||
status = ?out.status,
|
||||
stderr = %stderr,
|
||||
"claude plugin marketplace add failed (non-fatal)",
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(source = %source, error = ?e, "claude plugin marketplace add spawn failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the `hyperhive.claudePluginsAutoUpdate` flag written by the NixOS
|
||||
/// module. Defaults to `false` when the file is absent or unparseable.
|
||||
async fn auto_update_enabled() -> bool {
|
||||
match tokio::fs::read_to_string(AUTO_UPDATE_PATH).await {
|
||||
Ok(s) => serde_json::from_str::<bool>(s.trim()).unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update all configured plugin marketplaces. Non-fatal — logs a warning
|
||||
/// on failure but does not abort the install sequence.
|
||||
async fn update_marketplaces() {
|
||||
match Command::new("claude")
|
||||
.args(["plugin", "marketplace", "update"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) if out.status.success() => {
|
||||
tracing::info!("claude plugin marketplace update ok");
|
||||
}
|
||||
Ok(out) => {
|
||||
tracing::warn!(
|
||||
status = ?out.status,
|
||||
stderr = %String::from_utf8_lossy(&out.stderr),
|
||||
"claude plugin marketplace update failed (non-fatal)",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "claude plugin marketplace update spawn failed (non-fatal)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`. When
|
||||
/// `notify_recipient` is `Some(name)`, install failures also get sent
|
||||
/// as a hyperhive message to that recipient (typically `"manager"` for
|
||||
/// sub-agents) so it surfaces in the inbox rather than being buried in
|
||||
/// journald. The manager itself passes `None` — there's nobody above
|
||||
/// it to notify.
|
||||
pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) {
|
||||
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
|
||||
return;
|
||||
};
|
||||
let specs: Vec<String> = match serde_json::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if specs.is_empty() {
|
||||
return;
|
||||
}
|
||||
add_marketplaces().await;
|
||||
if auto_update_enabled().await {
|
||||
update_marketplaces().await;
|
||||
} else {
|
||||
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
|
||||
}
|
||||
for spec in specs {
|
||||
match Command::new("claude")
|
||||
.args(["plugin", "install", &spec])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) if out.status.success() => {
|
||||
tracing::info!(spec = %spec, "claude plugin install ok");
|
||||
}
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
tracing::warn!(
|
||||
spec = %spec,
|
||||
status = ?out.status,
|
||||
stderr = %stderr,
|
||||
"claude plugin install failed",
|
||||
);
|
||||
if let Some(to) = notify_recipient {
|
||||
notify(
|
||||
socket,
|
||||
to,
|
||||
format!(
|
||||
"claude plugin install failed for `{spec}`:\n{}",
|
||||
stderr.trim()
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
|
||||
if let Some(to) = notify_recipient {
|
||||
notify(
|
||||
socket,
|
||||
to,
|
||||
format!("claude plugin install spawn failed for `{spec}`: {e}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort hyperhive send. Swallows transport errors — the warn log
|
||||
/// is already in journald and the harness boot must not stall waiting
|
||||
/// for the broker to be reachable.
|
||||
async fn notify(socket: &Path, to: &str, body: String) {
|
||||
let req = hive_sh4re::AgentRequest::Send {
|
||||
to: to.to_owned(),
|
||||
body,
|
||||
in_reply_to: None,
|
||||
};
|
||||
if let Err(e) = client::request::<_, hive_sh4re::AgentResponse>(socket, &req).await {
|
||||
tracing::warn!(error = ?e, "failed to notify {to} of plugin install failure");
|
||||
}
|
||||
}
|
||||
93
hive-ag3nt/src/serve_common.rs
Normal file
93
hive-ag3nt/src/serve_common.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! Helpers shared between `hive-ag3nt` (agent) and `hive-m1nd` (manager)
|
||||
//! serve loops. Only pure functions with no wire-type dependency live here;
|
||||
//! request/response-flavored helpers (`requeue_inflight`, `ack_turn`, etc.)
|
||||
//! stay in each binary because they use different request enum variants.
|
||||
|
||||
use crate::events::Bus;
|
||||
use crate::mcp::REDELIVERY_HINT;
|
||||
use crate::turn::TurnOutcome;
|
||||
use crate::turn_stats::TurnStatRow;
|
||||
|
||||
/// Assemble the per-turn wake prompt string. The role/tools/etc. live in the
|
||||
/// system prompt; this is just the wake signal body. `unread` is the inbox
|
||||
/// depth after this message was popped. `redelivered` prepends a "may already
|
||||
/// be handled" banner.
|
||||
#[must_use]
|
||||
pub fn format_wake_prompt(from: &str, body: &str, unread: u64, redelivered: bool) -> String {
|
||||
let banner = if redelivered { REDELIVERY_HINT } else { "" };
|
||||
let pending = if unread == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"\n\n({unread} more message(s) pending in your inbox — call `mcp__hyperhive__recv` \
|
||||
with `max: {unread}` to drain them all in one round-trip before acting.)"
|
||||
)
|
||||
};
|
||||
format!("{banner}Incoming message from `{from}`:\n---\n{body}\n---{pending}")
|
||||
}
|
||||
|
||||
/// Current time as a Unix timestamp (seconds). Returns 0 on any error.
|
||||
#[must_use]
|
||||
pub fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Assemble a `TurnStatRow` from the harness's per-turn state. Used by both
|
||||
/// the agent and manager serve loops — the shape is identical, only the
|
||||
/// post-turn count fetch helpers differ (and those stay in each binary).
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_row(
|
||||
started_at: i64,
|
||||
ended_at: i64,
|
||||
duration_ms: i64,
|
||||
model: String,
|
||||
wake_from: String,
|
||||
outcome: &TurnOutcome,
|
||||
bus: &Bus,
|
||||
open_threads_count: Option<u64>,
|
||||
open_reminders_count: Option<u64>,
|
||||
) -> TurnStatRow {
|
||||
let cost = bus.last_cost_usage().unwrap_or_default();
|
||||
let ctx = bus.last_ctx_usage().unwrap_or(cost);
|
||||
let tool_calls = bus.take_tool_calls();
|
||||
let tool_call_count: u64 = tool_calls.values().copied().sum();
|
||||
let tool_call_breakdown_json = if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
serde_json::to_string(&tool_calls).ok()
|
||||
};
|
||||
let (result_kind, note) = match outcome {
|
||||
TurnOutcome::Ok => ("ok", None),
|
||||
TurnOutcome::Compacted => ("compacted", None),
|
||||
TurnOutcome::PromptTooLong => ("prompt_too_long", None),
|
||||
TurnOutcome::RateLimited => ("rate_limited", None),
|
||||
TurnOutcome::AuthFailed => ("auth_failed", None),
|
||||
TurnOutcome::Failed(e) => ("failed", Some(format!("{e:#}"))),
|
||||
};
|
||||
TurnStatRow {
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_ms,
|
||||
model,
|
||||
wake_from,
|
||||
input_tokens: cost.input_tokens,
|
||||
output_tokens: cost.output_tokens,
|
||||
cache_read_input_tokens: cost.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: cost.cache_creation_input_tokens,
|
||||
last_input_tokens: ctx.input_tokens,
|
||||
last_output_tokens: ctx.output_tokens,
|
||||
last_cache_read_input_tokens: ctx.cache_read_input_tokens,
|
||||
last_cache_creation_input_tokens: ctx.cache_creation_input_tokens,
|
||||
tool_call_count,
|
||||
tool_call_breakdown_json,
|
||||
open_threads_count,
|
||||
open_reminders_count,
|
||||
result_kind,
|
||||
note,
|
||||
}
|
||||
}
|
||||
557
hive-ag3nt/src/stats.rs
Normal file
557
hive-ag3nt/src/stats.rs
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
//! Read-side aggregations over the per-agent `turn_stats.sqlite` for
|
||||
//! the agent's `/stats` web page. Owned by the agent (same process
|
||||
//! that writes the sink) so per-MCP extensions can register more
|
||||
//! providers without the host needing to know their schemas.
|
||||
//!
|
||||
//! Best-effort: any sqlite error returns an empty snapshot rather than
|
||||
//! propagating — the stats page is decorative, not authoritative, and
|
||||
//! a missing db on a brand-new agent shouldn't 500 the route.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use serde::Serialize;
|
||||
|
||||
use hive_sh4re::ReminderStats;
|
||||
|
||||
/// Window param accepted by `/api/stats?window=`. Each maps to a
|
||||
/// total span + the bucket width used to roll up trend series.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Window {
|
||||
Hour,
|
||||
FourHour,
|
||||
Day,
|
||||
ThreeDay,
|
||||
Week,
|
||||
Month,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
#[must_use]
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"1h" => Self::Hour,
|
||||
"4h" => Self::FourHour,
|
||||
"3d" => Self::ThreeDay,
|
||||
"7d" => Self::Week,
|
||||
"30d" => Self::Month,
|
||||
_ => Self::Day,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hour => "1h",
|
||||
Self::FourHour => "4h",
|
||||
Self::Day => "24h",
|
||||
Self::ThreeDay => "3d",
|
||||
Self::Week => "7d",
|
||||
Self::Month => "30d",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn span_secs(self) -> i64 {
|
||||
match self {
|
||||
Self::Hour => 3600,
|
||||
Self::FourHour => 4 * 3600,
|
||||
Self::Day => 24 * 3600,
|
||||
Self::ThreeDay => 3 * 24 * 3600,
|
||||
Self::Week => 7 * 24 * 3600,
|
||||
Self::Month => 30 * 24 * 3600,
|
||||
}
|
||||
}
|
||||
|
||||
fn bucket_secs(self) -> i64 {
|
||||
match self {
|
||||
// 5-min buckets for 1h (12 buckets), 15-min for 4h (16 buckets),
|
||||
// hourly for 24h + 3d, daily for 7d + 30d.
|
||||
Self::Hour => 300,
|
||||
Self::FourHour => 900,
|
||||
Self::Day | Self::ThreeDay => 3600,
|
||||
Self::Week | Self::Month => 24 * 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Snapshot {
|
||||
pub window: &'static str,
|
||||
pub bucket_seconds: i64,
|
||||
pub now: i64,
|
||||
pub from: i64,
|
||||
/// Total turns in the window.
|
||||
pub turn_count: u64,
|
||||
/// Time-bucketed trend series, oldest first. Always covers the
|
||||
/// full window even for empty buckets (so charts paint a stable
|
||||
/// x-axis instead of skipping gaps).
|
||||
pub buckets: Vec<Bucket>,
|
||||
/// Top tools by call count across the window. Capped to 10.
|
||||
pub tool_breakdown: Vec<KeyCount>,
|
||||
pub wake_mix: Vec<KeyCount>,
|
||||
pub result_mix: Vec<KeyCount>,
|
||||
/// Distinct models seen in the window, sorted. Each bucket's
|
||||
/// `model_counts` keys into this set; the stats page uses it as
|
||||
/// the stacked-bar series list (stable order + colours).
|
||||
pub models: Vec<String>,
|
||||
/// Across-window p50 / p95 / avg of `duration_ms`. Same numbers
|
||||
/// as the per-bucket fields but aggregated over the whole window
|
||||
/// for the headline summary chips.
|
||||
pub duration_summary: DurationSummary,
|
||||
/// Reminder activity stats: counts of scheduled, delivered, and
|
||||
/// pending reminders over the window (fetched from the broker RPC).
|
||||
/// None if the RPC call failed or hasn't been integrated yet.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reminder_stats: Option<ReminderStats>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Bucket {
|
||||
/// Unix timestamp of the bucket start.
|
||||
pub ts: i64,
|
||||
pub turn_count: u64,
|
||||
pub avg_duration_ms: f64,
|
||||
pub p50_duration_ms: f64,
|
||||
pub p95_duration_ms: f64,
|
||||
/// Sums across the bucket. JS picks how to combine them
|
||||
/// (input + output for cost, etc.) so we don't bake a policy in.
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
/// Mean of `last_input_tokens` across the bucket (the context
|
||||
/// size at turn-end — useful for spotting drift toward compaction).
|
||||
pub avg_ctx_tokens: f64,
|
||||
pub max_ctx_tokens: u64,
|
||||
/// Turn count per model in this bucket. Model choice greatly
|
||||
/// affects token cost, so this lets the operator line model usage
|
||||
/// up against the cost series over time.
|
||||
pub model_counts: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct KeyCount {
|
||||
pub key: String,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct DurationSummary {
|
||||
pub avg_ms: f64,
|
||||
pub p50_ms: f64,
|
||||
pub p95_ms: f64,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn snapshot_default(window: Window) -> Snapshot {
|
||||
let path = default_path();
|
||||
match snapshot(&path, window) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, path = %path.display(), "stats: snapshot failed");
|
||||
empty_snapshot(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_path() -> PathBuf {
|
||||
crate::paths::state_dir().join("hyperhive-turn-stats.sqlite")
|
||||
}
|
||||
|
||||
fn empty_snapshot(window: Window) -> Snapshot {
|
||||
let now = now_secs();
|
||||
let from = now - window.span_secs();
|
||||
let buckets = fill_buckets(from, now, window.bucket_secs(), &HashMap::new());
|
||||
Snapshot {
|
||||
window: window.label(),
|
||||
bucket_seconds: window.bucket_secs(),
|
||||
now,
|
||||
from,
|
||||
turn_count: 0,
|
||||
buckets,
|
||||
tool_breakdown: Vec::new(),
|
||||
wake_mix: Vec::new(),
|
||||
result_mix: Vec::new(),
|
||||
models: Vec::new(),
|
||||
duration_summary: DurationSummary::default(),
|
||||
reminder_stats: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(path: &Path, window: Window) -> Result<Snapshot> {
|
||||
// Read-only open so an in-flight writer (the harness's own
|
||||
// turn_stats sink) never blocks us and we can't corrupt the db
|
||||
// via a query bug.
|
||||
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.with_context(|| format!("open {} read-only", path.display()))?;
|
||||
let now = now_secs();
|
||||
let from = now - window.span_secs();
|
||||
let bucket_secs = window.bucket_secs();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT started_at, duration_ms,
|
||||
input_tokens, output_tokens,
|
||||
cache_read_input_tokens, cache_creation_input_tokens,
|
||||
last_input_tokens,
|
||||
tool_call_breakdown_json,
|
||||
wake_from, result_kind, model
|
||||
FROM turn_stats
|
||||
WHERE started_at >= ?1
|
||||
ORDER BY started_at ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([from], |row| {
|
||||
Ok(Row {
|
||||
started_at: row.get(0)?,
|
||||
duration_ms: row.get::<_, i64>(1)?,
|
||||
input_tokens: u64_from_i64(row.get::<_, i64>(2)?),
|
||||
output_tokens: u64_from_i64(row.get::<_, i64>(3)?),
|
||||
cache_read_input_tokens: u64_from_i64(row.get::<_, i64>(4)?),
|
||||
cache_creation_input_tokens: u64_from_i64(row.get::<_, i64>(5)?),
|
||||
last_input_tokens: u64_from_i64(row.get::<_, i64>(6)?),
|
||||
tool_breakdown_json: row.get::<_, Option<String>>(7)?,
|
||||
wake_from: row.get::<_, String>(8)?,
|
||||
result_kind: row.get::<_, String>(9)?,
|
||||
model: row.get::<_, String>(10)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut by_bucket: HashMap<i64, BucketAcc> = HashMap::new();
|
||||
let mut tool_totals: HashMap<String, u64> = HashMap::new();
|
||||
let mut wake_totals: HashMap<String, u64> = HashMap::new();
|
||||
let mut result_totals: HashMap<String, u64> = HashMap::new();
|
||||
let mut model_set: HashSet<String> = HashSet::new();
|
||||
let mut all_durations: Vec<i64> = Vec::new();
|
||||
let mut turn_count: u64 = 0;
|
||||
|
||||
for r in rows {
|
||||
let r = r?;
|
||||
turn_count += 1;
|
||||
let bucket_ts = (r.started_at / bucket_secs) * bucket_secs;
|
||||
let acc = by_bucket.entry(bucket_ts).or_default();
|
||||
acc.turn_count += 1;
|
||||
acc.durations.push(r.duration_ms.max(0));
|
||||
acc.input_tokens = acc.input_tokens.saturating_add(r.input_tokens);
|
||||
acc.output_tokens = acc.output_tokens.saturating_add(r.output_tokens);
|
||||
acc.cache_read_input_tokens = acc
|
||||
.cache_read_input_tokens
|
||||
.saturating_add(r.cache_read_input_tokens);
|
||||
acc.cache_creation_input_tokens = acc
|
||||
.cache_creation_input_tokens
|
||||
.saturating_add(r.cache_creation_input_tokens);
|
||||
acc.ctx_sum = acc.ctx_sum.saturating_add(r.last_input_tokens);
|
||||
acc.ctx_max = acc.ctx_max.max(r.last_input_tokens);
|
||||
*acc.model_counts.entry(r.model.clone()).or_insert(0) += 1;
|
||||
|
||||
all_durations.push(r.duration_ms.max(0));
|
||||
*wake_totals.entry(r.wake_from).or_insert(0) += 1;
|
||||
*result_totals.entry(r.result_kind).or_insert(0) += 1;
|
||||
model_set.insert(r.model);
|
||||
|
||||
if let Some(json) = r.tool_breakdown_json
|
||||
&& let Ok(map) = serde_json::from_str::<HashMap<String, u64>>(&json)
|
||||
{
|
||||
for (k, v) in map {
|
||||
*tool_totals.entry(k).or_insert(0) += v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let buckets = fill_buckets(from, now, bucket_secs, &by_bucket);
|
||||
let duration_summary = summarize_durations(&mut all_durations);
|
||||
let mut models: Vec<String> = model_set.into_iter().collect();
|
||||
models.sort_unstable();
|
||||
|
||||
Ok(Snapshot {
|
||||
window: window.label(),
|
||||
bucket_seconds: bucket_secs,
|
||||
now,
|
||||
from,
|
||||
turn_count,
|
||||
buckets,
|
||||
tool_breakdown: top_n(tool_totals, 10),
|
||||
wake_mix: top_n(wake_totals, 20),
|
||||
result_mix: top_n(result_totals, 20),
|
||||
models,
|
||||
duration_summary,
|
||||
reminder_stats: None, // TODO: fetch via ReminderRollup RPC
|
||||
})
|
||||
}
|
||||
|
||||
struct Row {
|
||||
started_at: i64,
|
||||
duration_ms: i64,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cache_read_input_tokens: u64,
|
||||
cache_creation_input_tokens: u64,
|
||||
last_input_tokens: u64,
|
||||
tool_breakdown_json: Option<String>,
|
||||
wake_from: String,
|
||||
result_kind: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BucketAcc {
|
||||
turn_count: u64,
|
||||
durations: Vec<i64>,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cache_read_input_tokens: u64,
|
||||
cache_creation_input_tokens: u64,
|
||||
ctx_sum: u64,
|
||||
ctx_max: u64,
|
||||
model_counts: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
fn fill_buckets(
|
||||
from: i64,
|
||||
now: i64,
|
||||
bucket_secs: i64,
|
||||
by_bucket: &HashMap<i64, BucketAcc>,
|
||||
) -> Vec<Bucket> {
|
||||
let start = (from / bucket_secs) * bucket_secs;
|
||||
let mut out = Vec::new();
|
||||
let mut ts = start;
|
||||
while ts <= now {
|
||||
let bucket = if let Some(acc) = by_bucket.get(&ts) {
|
||||
let mut sorted = acc.durations.clone();
|
||||
sorted.sort_unstable();
|
||||
let avg = if sorted.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let sum_f = sorted.iter().sum::<i64>() as f64;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let len_f = sorted.len() as f64;
|
||||
sum_f / len_f
|
||||
};
|
||||
let p50 = percentile(&sorted, 50);
|
||||
let p95 = percentile(&sorted, 95);
|
||||
let avg_ctx = if acc.turn_count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let sum_f = acc.ctx_sum as f64;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let cnt_f = acc.turn_count as f64;
|
||||
sum_f / cnt_f
|
||||
};
|
||||
Bucket {
|
||||
ts,
|
||||
turn_count: acc.turn_count,
|
||||
avg_duration_ms: avg,
|
||||
p50_duration_ms: p50,
|
||||
p95_duration_ms: p95,
|
||||
input_tokens: acc.input_tokens,
|
||||
output_tokens: acc.output_tokens,
|
||||
cache_read_input_tokens: acc.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: acc.cache_creation_input_tokens,
|
||||
avg_ctx_tokens: avg_ctx,
|
||||
max_ctx_tokens: acc.ctx_max,
|
||||
model_counts: acc.model_counts.clone(),
|
||||
}
|
||||
} else {
|
||||
Bucket {
|
||||
ts,
|
||||
turn_count: 0,
|
||||
avg_duration_ms: 0.0,
|
||||
p50_duration_ms: 0.0,
|
||||
p95_duration_ms: 0.0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
avg_ctx_tokens: 0.0,
|
||||
max_ctx_tokens: 0,
|
||||
model_counts: HashMap::new(),
|
||||
}
|
||||
};
|
||||
out.push(bucket);
|
||||
ts += bucket_secs;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn summarize_durations(all: &mut [i64]) -> DurationSummary {
|
||||
if all.is_empty() {
|
||||
return DurationSummary::default();
|
||||
}
|
||||
all.sort_unstable();
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let sum_f = all.iter().sum::<i64>() as f64;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let len_f = all.len() as f64;
|
||||
DurationSummary {
|
||||
avg_ms: sum_f / len_f,
|
||||
p50_ms: percentile(all, 50),
|
||||
p95_ms: percentile(all, 95),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
fn percentile(sorted: &[i64], pct: u8) -> f64 {
|
||||
if sorted.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
if sorted.len() == 1 {
|
||||
return sorted[0] as f64;
|
||||
}
|
||||
// Nearest-rank, clamped.
|
||||
let rank = ((f64::from(pct) / 100.0) * (sorted.len() as f64 - 1.0)).round() as usize;
|
||||
sorted[rank.min(sorted.len() - 1)] as f64
|
||||
}
|
||||
|
||||
fn top_n(map: HashMap<String, u64>, n: usize) -> Vec<KeyCount> {
|
||||
let mut v: Vec<KeyCount> = map
|
||||
.into_iter()
|
||||
.map(|(key, count)| KeyCount { key, count })
|
||||
.collect();
|
||||
v.sort_unstable_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key)));
|
||||
v.truncate(n);
|
||||
v
|
||||
}
|
||||
|
||||
fn u64_from_i64(v: i64) -> u64 {
|
||||
u64::try_from(v).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::params;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static SEQ: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn tmp_db() -> PathBuf {
|
||||
let n = SEQ.fetch_add(1, Ordering::SeqCst);
|
||||
let pid = std::process::id();
|
||||
std::env::temp_dir().join(format!("hyperhive-stats-test-{pid}-{n}.sqlite"))
|
||||
}
|
||||
|
||||
fn seed_db(path: &Path, rows: &[(i64, i64, &str, &str, &str, &str)]) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE turn_stats (
|
||||
id INTEGER PRIMARY KEY,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
wake_from TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
tool_call_count INTEGER NOT NULL DEFAULT 0,
|
||||
tool_call_breakdown_json TEXT,
|
||||
open_threads_count INTEGER,
|
||||
open_reminders_count INTEGER,
|
||||
result_kind TEXT NOT NULL,
|
||||
note TEXT
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
for (started, dur, model, wake, result, tools_json) in rows {
|
||||
conn.execute(
|
||||
"INSERT INTO turn_stats
|
||||
(started_at, ended_at, duration_ms, model, wake_from,
|
||||
last_input_tokens, tool_call_breakdown_json, result_kind)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 1000, ?6, ?7)",
|
||||
params![started, started + dur / 1000, dur, model, wake, tools_json, result],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_aggregates_rows() {
|
||||
let db = tmp_db();
|
||||
let _ = std::fs::remove_file(&db);
|
||||
let now = now_secs();
|
||||
seed_db(
|
||||
&db,
|
||||
&[
|
||||
(now - 600, 5_000, "opus", "recv", "ok", r#"{"Read":2,"Bash":1}"#),
|
||||
(now - 300, 10_000, "opus", "recv", "ok", r#"{"Read":3}"#),
|
||||
(now - 100, 20_000, "sonnet", "operator", "failed", "{}"),
|
||||
],
|
||||
);
|
||||
let s = snapshot(&db, Window::Day).unwrap();
|
||||
assert_eq!(s.turn_count, 3);
|
||||
assert_eq!(s.window, "24h");
|
||||
assert_eq!(s.bucket_seconds, 3600);
|
||||
let tool_map: HashMap<_, _> = s
|
||||
.tool_breakdown
|
||||
.iter()
|
||||
.map(|kc| (kc.key.clone(), kc.count))
|
||||
.collect();
|
||||
assert_eq!(tool_map.get("Read").copied(), Some(5));
|
||||
assert_eq!(tool_map.get("Bash").copied(), Some(1));
|
||||
let wake_map: HashMap<_, _> = s
|
||||
.wake_mix
|
||||
.iter()
|
||||
.map(|kc| (kc.key.clone(), kc.count))
|
||||
.collect();
|
||||
assert_eq!(wake_map.get("recv").copied(), Some(2));
|
||||
assert_eq!(wake_map.get("operator").copied(), Some(1));
|
||||
let result_map: HashMap<_, _> = s
|
||||
.result_mix
|
||||
.iter()
|
||||
.map(|kc| (kc.key.clone(), kc.count))
|
||||
.collect();
|
||||
assert_eq!(result_map.get("ok").copied(), Some(2));
|
||||
assert_eq!(result_map.get("failed").copied(), Some(1));
|
||||
// Model breakdown: 2 opus + 1 sonnet, all in the same hour
|
||||
// bucket given the 24h window.
|
||||
assert_eq!(s.models, vec!["opus".to_string(), "sonnet".to_string()]);
|
||||
let mut model_totals: HashMap<String, u64> = HashMap::new();
|
||||
for b in &s.buckets {
|
||||
for (k, v) in &b.model_counts {
|
||||
*model_totals.entry(k.clone()).or_insert(0) += v;
|
||||
}
|
||||
}
|
||||
assert_eq!(model_totals.get("opus").copied(), Some(2));
|
||||
assert_eq!(model_totals.get("sonnet").copied(), Some(1));
|
||||
// Durations: [5000, 10000, 20000] → avg ≈ 11666.67, p50 = 10000, p95 ~ 20000
|
||||
assert!((s.duration_summary.avg_ms - 11_666.666_666_666_666).abs() < 1.0);
|
||||
assert!((s.duration_summary.p50_ms - 10_000.0).abs() < 1.0);
|
||||
assert!((s.duration_summary.p95_ms - 20_000.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_window_still_paints_buckets() {
|
||||
let db = tmp_db();
|
||||
let _ = std::fs::remove_file(&db);
|
||||
seed_db(&db, &[]);
|
||||
let s = snapshot(&db, Window::Day).unwrap();
|
||||
assert_eq!(s.turn_count, 0);
|
||||
// 24h / 1h buckets = ~24-25 buckets covering the window.
|
||||
assert!(s.buckets.len() >= 24);
|
||||
assert!(s.buckets.iter().all(|b| b.turn_count == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn week_uses_daily_buckets() {
|
||||
let db = tmp_db();
|
||||
let _ = std::fs::remove_file(&db);
|
||||
seed_db(&db, &[]);
|
||||
let s = snapshot(&db, Window::Week).unwrap();
|
||||
assert_eq!(s.window, "7d");
|
||||
assert_eq!(s.bucket_seconds, 86_400);
|
||||
assert!(s.buckets.len() >= 7);
|
||||
}
|
||||
}
|
||||
753
hive-ag3nt/src/turn.rs
Normal file
753
hive-ag3nt/src/turn.rs
Normal file
|
|
@ -0,0 +1,753 @@
|
|||
//! Per-turn claude invocation shared by `hive-ag3nt` and `hive-m1nd`. The
|
||||
//! two binaries differ only in their MCP `Flavor` (agent surface vs.
|
||||
//! manager surface) and their wake-prompt wording; the spawn shape,
|
||||
//! arg-vector, stdin plumbing, and stream-json pumping are identical.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::events::{Bus, LiveEvent};
|
||||
use crate::login::{self, LoginState};
|
||||
use crate::mcp;
|
||||
|
||||
/// `--settings` JSON applied to every claude invocation. Lives as a
|
||||
/// properly-formatted file in `prompts/claude-settings.json` so it's easy
|
||||
/// to read and edit; we ship it via `include_str!`. We turn off claude's
|
||||
/// in-session auto-compaction and its cross-session auto-memory because
|
||||
/// hyperhive owns those concerns (`/compact` on overflow, notes
|
||||
/// persistence under `/state`). Unknown keys are silently ignored by
|
||||
/// claude-code; if a key gets renamed we'll spot it because the
|
||||
/// corresponding behavior will start firing mid-turn again.
|
||||
const CLAUDE_SETTINGS: &str = include_str!("../prompts/claude-settings.json");
|
||||
|
||||
/// Regex-ish marker claude-code emits when context overflows. Same string
|
||||
/// bitburner-agent watches for. Empirically reliable across claude-code
|
||||
/// versions; if it ever changes, compaction won't fire and we'll see a
|
||||
/// claude exit with a useful error in the live view.
|
||||
const PROMPT_TOO_LONG_MARKER: &str = "Prompt is too long";
|
||||
|
||||
/// Substrings that indicate the Anthropic API is refusing the request due
|
||||
/// to a rate limit, per-account usage cap, or exhausted credit balance.
|
||||
/// Matched against both stdout and stderr; any hit returns
|
||||
/// `TurnOutcome::RateLimited` so the serve loop can park + retry instead
|
||||
/// of propagating a hard failure that looks identical to a crash.
|
||||
const RATE_LIMIT_MARKERS: &[&str] = &[
|
||||
"rate_limit_error",
|
||||
"overloaded_error",
|
||||
"Credit balance is too low",
|
||||
"Usage limit reached",
|
||||
"Request rate limit exceeded",
|
||||
];
|
||||
|
||||
/// Substrings that indicate the Anthropic API rejected the request as
|
||||
/// unauthenticated — the OAuth session in `/root/.claude/` has expired
|
||||
/// or been revoked. Surfaced as `TurnOutcome::AuthFailed`, which the
|
||||
/// harness uses to flip the container into `needs_login_idle` so the
|
||||
/// dashboard's re-auth flow takes over (closes #419). Matched against
|
||||
/// both stdout JSON `error` events and stderr; the markers come from
|
||||
/// claude-code's `api_retry` events (`{"error":"authentication_failed",
|
||||
/// "error_status":401,...}`) and the human-readable
|
||||
/// "Failed to authenticate. API Error: 401" line claude prints on giveup.
|
||||
const AUTH_FAIL_MARKERS: &[&str] = &[
|
||||
"\"error\":\"authentication_failed\"",
|
||||
"\"error_status\":401",
|
||||
"Failed to authenticate. API Error: 401",
|
||||
];
|
||||
|
||||
/// How long to sleep after detecting a rate-limit before re-entering the
|
||||
/// serve loop. Overridable via `HIVE_RATE_LIMIT_SLEEP_SECS`. Default is
|
||||
/// 5 minutes — enough for most short-lived throttles; the operator can
|
||||
/// tune down for tight retry scenarios or up if they're hitting sustained
|
||||
/// capacity limits.
|
||||
const DEFAULT_RATE_LIMIT_SLEEP_SECS: u64 = 300;
|
||||
|
||||
/// Assumed prompt-cache TTL. Claude caches prompt prefixes — ~5 minutes on
|
||||
/// the API (pay-per-token), ~1 hour on Claude Max (subscription). When the
|
||||
/// idle gap exceeds this, the cache prefix has likely expired and the next
|
||||
/// turn re-uploads the full transcript regardless of whether we resume or
|
||||
/// start fresh. A fresh session with a small context is therefore equally
|
||||
/// cheap but gives the model a clean slate. Default is 3600s (1h) matching
|
||||
/// the subscription TTL; API (pay-per-token) users should set
|
||||
/// `HIVE_CACHE_TTL_SECS=300`. Override via `HIVE_CACHE_TTL_SECS`; set to
|
||||
/// `0` to disable (always resume).
|
||||
const DEFAULT_CACHE_TTL_SECS: u64 = 3600;
|
||||
|
||||
/// Synthetic wake prompt for the proactive notes-checkpoint turn. Not an
|
||||
/// inbox message — the harness injects it directly so the agent gets one
|
||||
/// turn to persist durable state before `/compact` collapses the
|
||||
/// turn-by-turn history into a summary.
|
||||
const CHECKPOINT_PROMPT: &str = "[system] Context checkpoint — no inbox message to handle.\n\n\
|
||||
Your conversation context has grown large and the harness is about to run `/compact`, \
|
||||
which collapses the detailed turn-by-turn history into a short summary. Anything you \
|
||||
do not persist now is effectively lost after the next turn.\n\n\
|
||||
Use THIS turn to flush anything worth keeping into your durable `/state` files: update \
|
||||
your notes / CLAUDE.md / TODO.md with in-flight task state, decisions made, important \
|
||||
file paths, and whatever you would need to resume cleanly with only a summary of this \
|
||||
conversation to go on. Do not start new work or reply to anyone — just write your notes \
|
||||
and end the turn.";
|
||||
|
||||
/// The set of files claude reads on every invocation: the MCP server
|
||||
/// config (`--mcp-config`), static settings (`--settings`), and the
|
||||
/// pre-rendered role/tools system prompt (`--system-prompt-file`).
|
||||
/// Materialised once at harness startup; shared between the turn loop
|
||||
/// and the operator-driven `/compact` path so both invocations look
|
||||
/// identical to claude (same MCP surface, same allowed tools, same
|
||||
/// role prompt — only the stdin payload differs).
|
||||
#[derive(Clone)]
|
||||
pub struct TurnFiles {
|
||||
pub mcp_config: PathBuf,
|
||||
pub settings: PathBuf,
|
||||
pub system_prompt: PathBuf,
|
||||
pub flavor: mcp::Flavor,
|
||||
}
|
||||
|
||||
impl TurnFiles {
|
||||
/// Write all three files into the per-agent runtime dir alongside
|
||||
/// `socket`. Idempotent — overwrites whatever was there.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if any of the config files cannot be written to disk.
|
||||
pub async fn prepare(socket: &Path, label: &str, flavor: mcp::Flavor) -> Result<Self> {
|
||||
Ok(Self {
|
||||
mcp_config: write_mcp_config(socket).await?,
|
||||
settings: write_settings(socket).await?,
|
||||
system_prompt: write_system_prompt(socket, label, flavor).await?,
|
||||
flavor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
|
||||
/// `socket` is the hyperhive per-container socket (forwarded to the child
|
||||
/// as `--socket <path>`); `binary_subcommand` is e.g. `"mcp"` for sub-agents
|
||||
/// or `"mcp"` for the manager (both binaries name their MCP subcommand the
|
||||
/// same — the differentiator is which binary `/proc/self/exe` resolves to).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the config file cannot be written.
|
||||
pub async fn write_mcp_config(socket: &Path) -> Result<PathBuf> {
|
||||
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
|
||||
tokio::fs::create_dir_all(parent).await.ok();
|
||||
let path = parent.join("claude-mcp-config.json");
|
||||
let exe = std::env::current_exe()
|
||||
.ok()
|
||||
.map_or_else(|| "hive-ag3nt".into(), |p| p.display().to_string());
|
||||
let body = mcp::render_claude_config(&exe, socket);
|
||||
tokio::fs::write(&path, body).await?;
|
||||
tracing::info!(path = %path.display(), "wrote claude MCP config");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Drop the static `--settings` JSON next to the MCP config so we can
|
||||
/// pass a path (`--settings <file>`) instead of an ever-growing inline
|
||||
/// blob — the CLI argv has a finite length budget.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the settings file cannot be written.
|
||||
pub async fn write_settings(socket: &Path) -> Result<PathBuf> {
|
||||
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
|
||||
tokio::fs::create_dir_all(parent).await.ok();
|
||||
let path = parent.join("claude-settings.json");
|
||||
tokio::fs::write(&path, CLAUDE_SETTINGS).await?;
|
||||
tracing::info!(path = %path.display(), "wrote claude settings");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Write the agent's / manager's static system prompt to a file next to
|
||||
/// the MCP config and return the path. Passed to claude via
|
||||
/// `--system-prompt-file`, replacing claude's default system prompt with
|
||||
/// the role + tools instructions. Per-turn prompts become much smaller
|
||||
/// (just the wake message body).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the system prompt file cannot be written.
|
||||
pub async fn write_system_prompt(
|
||||
socket: &Path,
|
||||
label: &str,
|
||||
flavor: mcp::Flavor,
|
||||
) -> Result<PathBuf> {
|
||||
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
|
||||
tokio::fs::create_dir_all(parent).await.ok();
|
||||
let template = match flavor {
|
||||
mcp::Flavor::Agent => include_str!("../prompts/agent.md"),
|
||||
mcp::Flavor::Manager => include_str!("../prompts/manager.md"),
|
||||
};
|
||||
let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned());
|
||||
let body = template
|
||||
.replace("{label}", label)
|
||||
.replace("{operator_pronouns}", &pronouns);
|
||||
let path = parent.join("claude-system-prompt.md");
|
||||
tokio::fs::write(&path, body).await?;
|
||||
tracing::info!(path = %path.display(), "wrote claude system prompt");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// One claude turn's outcome. The harness uses this to decide whether to
|
||||
/// transparently kick off a compaction and retry.
|
||||
#[derive(Debug)]
|
||||
pub enum TurnOutcome {
|
||||
Ok,
|
||||
/// Turn completed and proactive context-size compaction fired afterwards.
|
||||
/// Treated like `Ok` for ack and failure-notification purposes; recorded
|
||||
/// as `result_kind = "compacted"` in turn stats so the stats page can
|
||||
/// distinguish normal turns from turns that triggered a compaction.
|
||||
Compacted,
|
||||
/// claude saw "Prompt is too long" — the session needs compacting.
|
||||
/// Run `compact_session()` then retry the same wake-up prompt.
|
||||
PromptTooLong,
|
||||
/// The Anthropic API refused the request due to a rate limit, per-account
|
||||
/// usage cap, or exhausted credit balance. The serve loop should park for
|
||||
/// `rate_limit_sleep_secs()` and retry — NOT bubble up as a crash.
|
||||
RateLimited,
|
||||
/// The Anthropic API rejected the request with 401 (OAuth session
|
||||
/// expired or revoked). The serve loop should flip the container
|
||||
/// into `needs_login_idle` and stop driving turns until the
|
||||
/// operator re-auths via the per-agent web UI (closes #419).
|
||||
AuthFailed,
|
||||
Failed(anyhow::Error),
|
||||
}
|
||||
|
||||
/// How long to sleep after a rate-limit before re-entering the serve loop.
|
||||
/// Reads `HIVE_RATE_LIMIT_SLEEP_SECS` if set to a valid positive integer.
|
||||
#[must_use]
|
||||
pub fn rate_limit_sleep_secs() -> u64 {
|
||||
std::env::var("HIVE_RATE_LIMIT_SLEEP_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or(DEFAULT_RATE_LIMIT_SLEEP_SECS)
|
||||
}
|
||||
|
||||
/// Resolve the effective context-window size for watermark calculations.
|
||||
/// Priority order (first wins):
|
||||
/// 1. API-reported window from the last `result` event's `modelUsage.*.contextWindow`.
|
||||
/// 2. `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars (Nix-configured per-model defaults).
|
||||
/// 3. Hard fallback: 200 000.
|
||||
///
|
||||
/// The API-reported window is the authoritative per-inference active
|
||||
/// context limit. It reflects what the model actually enforces — which
|
||||
/// for models with large prompt caches (e.g. 1 M total cache) may be
|
||||
/// significantly smaller than the cache capacity (e.g. 200 k active window
|
||||
/// for `claude-sonnet-4-6`).
|
||||
fn effective_context_window(bus: &Bus) -> u64 {
|
||||
bus.api_context_window()
|
||||
.unwrap_or_else(|| crate::events::context_window_tokens(&bus.model()))
|
||||
}
|
||||
|
||||
/// Resolve the auto-reset watermark. Priority order:
|
||||
/// 1. `HIVE_AUTO_RESET_WATERMARK_TOKENS` env var (explicit override).
|
||||
/// 2. 50% of `effective_context_window(bus)`.
|
||||
/// `0` disables auto-reset entirely.
|
||||
fn auto_reset_watermark_tokens(bus: &Bus) -> u64 {
|
||||
if let Some(v) = std::env::var("HIVE_AUTO_RESET_WATERMARK_TOKENS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
{
|
||||
return v;
|
||||
}
|
||||
effective_context_window(bus) / 2
|
||||
}
|
||||
|
||||
/// Resolve the assumed cache TTL: `HIVE_CACHE_TTL_SECS` if set, else
|
||||
/// `DEFAULT_CACHE_TTL_SECS`.
|
||||
fn cache_ttl_secs() -> u64 {
|
||||
std::env::var("HIVE_CACHE_TTL_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or(DEFAULT_CACHE_TTL_SECS)
|
||||
}
|
||||
|
||||
/// Resolve the proactive-compaction watermark. Priority order:
|
||||
/// 1. `HIVE_COMPACT_WATERMARK_TOKENS` env var (explicit override).
|
||||
/// 2. 75% of `effective_context_window(bus)`.
|
||||
/// `0` disables proactive compaction (reactive path still applies).
|
||||
fn compact_watermark_tokens(bus: &Bus) -> u64 {
|
||||
if let Some(v) = std::env::var("HIVE_COMPACT_WATERMARK_TOKENS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
{
|
||||
return v;
|
||||
}
|
||||
effective_context_window(bus) * 3 / 4
|
||||
}
|
||||
|
||||
/// Drive one turn end-to-end. Three paths layer on top of the raw `run_turn`:
|
||||
///
|
||||
/// - **Auto-reset (pre-turn)** — context is large AND the prompt cache has
|
||||
/// gone cold (idle gap ≥ cache TTL). Resuming would re-upload the full
|
||||
/// transcript uncached at the same cost as a fresh start. The harness runs
|
||||
/// one checkpoint turn (agent flushes state), then arms a one-shot
|
||||
/// `request_new_session` so the actual turn starts fresh.
|
||||
/// - **Reactive (on overflow)** — `run_turn` returns `PromptTooLong`: the
|
||||
/// session is already past the context window and *no* turn can run on it,
|
||||
/// so we compact immediately and retry the same wake-up prompt once. No
|
||||
/// notes-checkpoint turn is possible here — the detail is gone.
|
||||
/// - **Proactive (post-turn)** — the turn finished cleanly but its context
|
||||
/// size has crept past the watermark: while the session is still healthy we
|
||||
/// give the agent one dedicated turn to checkpoint its `/state` notes, then
|
||||
/// compact. This keeps a later turn from hitting the reactive path (where
|
||||
/// there is no chance to save anything first).
|
||||
///
|
||||
/// Both the sub-agent and manager loops call this.
|
||||
pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome {
|
||||
maybe_auto_reset(bus);
|
||||
let outcome = match run_turn(prompt, files, bus).await {
|
||||
TurnOutcome::PromptTooLong => {
|
||||
// Compact has its own three-flag surface (it's the same claude
|
||||
// binary). Treat any non-Ok outcome the same as if `run_turn`
|
||||
// had returned it — the serve loop already knows what to do
|
||||
// with each variant, no point re-wrapping. PromptTooLong from
|
||||
// /compact itself would be absurd recursion; bubble it up as
|
||||
// a normal failure path.
|
||||
match compact_session(files, bus).await {
|
||||
TurnOutcome::Ok | TurnOutcome::Compacted => {
|
||||
run_turn(prompt, files, bus).await
|
||||
}
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
// Rate-limited: no point retrying immediately — bubble up so the
|
||||
// serve loop can park + emit status before the next attempt.
|
||||
TurnOutcome::RateLimited => return TurnOutcome::RateLimited,
|
||||
other => other,
|
||||
};
|
||||
// Proactive: a turn just completed on a still-healthy session. If its
|
||||
// context crossed the watermark, checkpoint + compact before a later
|
||||
// turn overflows into the reactive path. Best-effort — never changes
|
||||
// the outcome of the turn that already succeeded, but records it as
|
||||
// `Compacted` so turn stats can distinguish it from a plain `Ok`.
|
||||
if matches!(outcome, TurnOutcome::Ok)
|
||||
&& maybe_checkpoint_and_compact(files, bus).await {
|
||||
return TurnOutcome::Compacted;
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Proactive post-turn compaction. If the last inference's context size
|
||||
/// has crossed the watermark, run one notes-checkpoint turn so the agent
|
||||
/// can persist durable state, then `/compact`. Best-effort: a failed
|
||||
/// checkpoint or compaction is logged + surfaced as a Note but never
|
||||
/// fails the turn that already succeeded. Returns `true` if compaction
|
||||
/// was attempted (watermark crossed), `false` if skipped.
|
||||
async fn maybe_checkpoint_and_compact(files: &TurnFiles, bus: &Bus) -> bool {
|
||||
let watermark = compact_watermark_tokens(bus);
|
||||
if watermark == 0 {
|
||||
return false; // proactive compaction disabled
|
||||
}
|
||||
let Some(used) = bus.last_ctx_usage().map(|u| u.context_tokens()) else {
|
||||
return false; // no usage reading yet — nothing to compare against
|
||||
};
|
||||
if used < watermark {
|
||||
return false;
|
||||
}
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!(
|
||||
"context at {used} tokens (watermark {watermark}) — running a \
|
||||
notes-checkpoint turn before /compact"
|
||||
),
|
||||
});
|
||||
// Give the agent one turn to flush durable state into /state. If the
|
||||
// session is somehow already too far gone to run even this, fall
|
||||
// through to compaction anyway — the checkpoint is best-effort.
|
||||
match run_turn(CHECKPOINT_PROMPT, files, bus).await {
|
||||
TurnOutcome::Ok | TurnOutcome::Compacted => {}
|
||||
TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note {
|
||||
text: "checkpoint turn overflowed the window — compacting without it".into(),
|
||||
}),
|
||||
TurnOutcome::RateLimited => bus.emit(LiveEvent::Note {
|
||||
text: "checkpoint turn was rate-limited — compacting anyway".into(),
|
||||
}),
|
||||
TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note {
|
||||
text: "checkpoint turn hit 401 — skipping compaction, parking for re-login".into(),
|
||||
}),
|
||||
TurnOutcome::Failed(e) => bus.emit(LiveEvent::Note {
|
||||
text: format!("checkpoint turn failed ({e:#}) — compacting anyway"),
|
||||
}),
|
||||
}
|
||||
// Best-effort: never changes the outcome of the turn that already
|
||||
// succeeded. Mirror the checkpoint-turn handling above — emit a Note
|
||||
// for each failure mode and move on; the next real turn will surface
|
||||
// the underlying issue (rate-limit / 401 / etc.) through the normal
|
||||
// path anyway.
|
||||
match compact_session(files, bus).await {
|
||||
TurnOutcome::Ok | TurnOutcome::Compacted => {}
|
||||
TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note {
|
||||
text: "/compact unexpectedly returned PromptTooLong — next turn will retry".into(),
|
||||
}),
|
||||
TurnOutcome::RateLimited => bus.emit(LiveEvent::Note {
|
||||
text: "/compact was rate-limited — next turn will park + retry".into(),
|
||||
}),
|
||||
TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note {
|
||||
text: "/compact hit 401 — next turn will trigger the re-login flow".into(),
|
||||
}),
|
||||
TurnOutcome::Failed(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "post-checkpoint compact failed");
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!("/compact after checkpoint failed: {e:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Pre-turn auto-reset check. If context is large AND the prompt cache has
|
||||
/// gone cold (idle time >= cache TTL), arm `request_new_session` so the
|
||||
/// next wake-up turn starts fresh. No preceding checkpoint turn — running
|
||||
/// any turn before the reset would re-upload and re-warm the cache, which
|
||||
/// defeats the cost-optimisation purpose entirely.
|
||||
fn maybe_auto_reset(bus: &Bus) {
|
||||
let watermark = auto_reset_watermark_tokens(bus);
|
||||
if watermark == 0 {
|
||||
return; // auto-reset disabled
|
||||
}
|
||||
let Some(ctx_tokens) = bus.last_ctx_usage().map(|u| u.context_tokens()) else {
|
||||
return; // no usage reading yet — first turn, nothing to reset
|
||||
};
|
||||
if ctx_tokens < watermark {
|
||||
return;
|
||||
}
|
||||
let last_ended = bus.last_turn_ended_unix();
|
||||
if last_ended == 0 {
|
||||
return; // no completed turn yet
|
||||
}
|
||||
// Compute idle seconds using the same clock as now_unix (unix epoch, i64).
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let idle_secs = now.saturating_sub(u64::try_from(last_ended).unwrap_or(0));
|
||||
let ttl = cache_ttl_secs();
|
||||
if idle_secs < ttl {
|
||||
return;
|
||||
}
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!(
|
||||
"context {ctx_tokens} tokens, idle {idle_secs}s >= cache TTL {ttl}s \
|
||||
— dropping session (cache cold, fresh start is equally cheap)"
|
||||
),
|
||||
});
|
||||
bus.request_new_session();
|
||||
}
|
||||
|
||||
/// Emit the per-turn `TurnEnd` event + log line. Single owner so the
|
||||
/// agent and manager loops agree on outcome semantics.
|
||||
pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
|
||||
match outcome {
|
||||
TurnOutcome::Ok | TurnOutcome::Compacted | TurnOutcome::PromptTooLong => {
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: true,
|
||||
note: None,
|
||||
});
|
||||
tracing::info!("turn finished");
|
||||
}
|
||||
TurnOutcome::RateLimited => {
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: false,
|
||||
note: Some("rate limited — parking until quota resets".into()),
|
||||
});
|
||||
tracing::warn!("turn rate-limited");
|
||||
}
|
||||
TurnOutcome::AuthFailed => {
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: false,
|
||||
note: Some("authentication failed (401) — waiting for re-login".into()),
|
||||
});
|
||||
tracing::warn!("turn auth-failed (401)");
|
||||
}
|
||||
TurnOutcome::Failed(e) => {
|
||||
let note = format!("{e:#}");
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: false,
|
||||
note: Some(note.clone()),
|
||||
});
|
||||
tracing::warn!(error = %note, "turn failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Block until the bound `~/.claude/` dir contains a session, polling
|
||||
/// `claude_dir` on a `poll_ms` interval (min 2s). Flips `state` to
|
||||
/// `Online` when login lands; caller resumes its serve loop.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal login-state lock is poisoned.
|
||||
pub async fn wait_for_login(
|
||||
claude_dir: &Path,
|
||||
state: Arc<Mutex<LoginState>>,
|
||||
bus: &Bus,
|
||||
poll_ms: u64,
|
||||
) {
|
||||
tracing::warn!(
|
||||
claude_dir = %claude_dir.display(),
|
||||
"no claude session — staying in partial-run mode (web UI only)"
|
||||
);
|
||||
let probe = Duration::from_millis(poll_ms.max(2000));
|
||||
loop {
|
||||
tokio::time::sleep(probe).await;
|
||||
if login::has_session(claude_dir) {
|
||||
tracing::info!("claude session detected — entering turn loop");
|
||||
*state.lock().unwrap() = LoginState::Online;
|
||||
bus.emit_status("online");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `claude` for one turn and pump `stream-json` stdout into the
|
||||
/// live event bus. Prompt goes over stdin (variadic
|
||||
/// `--allowedTools`/`--tools` would otherwise eat a trailing positional
|
||||
/// prompt). The session is persistent across turns via `--continue` and
|
||||
/// claude's in-session auto-compact is disabled via `--settings` so it
|
||||
/// doesn't stall mid-turn — hyperhive owns compaction.
|
||||
pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome {
|
||||
match run_claude(prompt, files, bus).await {
|
||||
Ok((true, _, _)) => TurnOutcome::PromptTooLong,
|
||||
Ok((_, true, _)) => TurnOutcome::RateLimited,
|
||||
Ok((_, _, true)) => TurnOutcome::AuthFailed,
|
||||
Ok(_) => TurnOutcome::Ok,
|
||||
Err(e) => TurnOutcome::Failed(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run claude's built-in `/compact` slash command on the persistent
|
||||
/// session. Takes the *same* params as `run_turn` because compact
|
||||
/// re-initialises claude with the full session shape — same MCP
|
||||
/// surface, same system prompt, same allowed-tools — so the post-
|
||||
/// compact state matches a normal turn's. Only the prompt over stdin
|
||||
/// differs (`/compact` vs the wake-up payload).
|
||||
///
|
||||
/// Returns the same `TurnOutcome` shape as `run_turn` so callers can
|
||||
/// react identically to all three failure flags (`prompt_too_long`,
|
||||
/// `rate_limited`, `auth_failed`). The reactive caller bubbles any
|
||||
/// non-Ok outcome up so the serve loop's normal handling (park +
|
||||
/// retry on rate-limit, flip to `needs_login` on 401, etc.) kicks in;
|
||||
/// the proactive post-checkpoint caller stays best-effort and only
|
||||
/// emits a Note for each failure mode.
|
||||
pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> TurnOutcome {
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "context overflow — running /compact on the persistent session".into(),
|
||||
});
|
||||
let outcome = match run_claude("/compact", files, bus).await {
|
||||
Ok((true, _, _)) => TurnOutcome::PromptTooLong,
|
||||
Ok((_, true, _)) => TurnOutcome::RateLimited,
|
||||
Ok((_, _, true)) => TurnOutcome::AuthFailed,
|
||||
Ok(_) => TurnOutcome::Ok,
|
||||
Err(e) => TurnOutcome::Failed(e),
|
||||
};
|
||||
match &outcome {
|
||||
TurnOutcome::Ok | TurnOutcome::Compacted => bus.emit(LiveEvent::Note {
|
||||
text: "/compact done".into(),
|
||||
}),
|
||||
TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note {
|
||||
text: "/compact reported PromptTooLong — bubbling up".into(),
|
||||
}),
|
||||
TurnOutcome::RateLimited => bus.emit(LiveEvent::Note {
|
||||
text: "/compact was rate-limited — bubbling up".into(),
|
||||
}),
|
||||
TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note {
|
||||
text: "/compact hit 401 — bubbling up for re-login flow".into(),
|
||||
}),
|
||||
TurnOutcome::Failed(e) => bus.emit(LiveEvent::Note {
|
||||
text: format!("/compact failed: {e:#}"),
|
||||
}),
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, bool, bool)> {
|
||||
// Keep the last STDERR_TAIL_LINES of stderr so a non-zero exit can
|
||||
// include real context in the bail message (and downstream in the
|
||||
// failure notification to the manager) instead of just "exit 1".
|
||||
const STDERR_TAIL_LINES: usize = 20;
|
||||
let model = bus.model();
|
||||
let resume = !bus.take_skip_continue();
|
||||
if !resume {
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "fresh session (--continue suppressed for this turn)".into(),
|
||||
});
|
||||
}
|
||||
let mut cmd = Command::new("claude");
|
||||
// Spawn inside the agent's state dir so relative paths in tool calls
|
||||
// (Read foo.md, Bash ls, Write notes.md) land in the durable dir
|
||||
// instead of wherever the harness systemd unit started. Falls back
|
||||
// silently if the dir is missing (dev / test without the bind mount).
|
||||
let state_dir = crate::paths::state_dir();
|
||||
if state_dir.is_dir() {
|
||||
cmd.current_dir(&state_dir);
|
||||
}
|
||||
cmd.arg("--print")
|
||||
.arg("--verbose")
|
||||
.arg("--output-format")
|
||||
.arg("stream-json")
|
||||
.arg("--model")
|
||||
.arg(&model)
|
||||
.arg("--settings")
|
||||
.arg(&files.settings);
|
||||
if resume {
|
||||
cmd.arg("--continue");
|
||||
}
|
||||
cmd.arg("--system-prompt-file").arg(&files.system_prompt);
|
||||
cmd.arg("--mcp-config")
|
||||
.arg(&files.mcp_config)
|
||||
.arg("--strict-mcp-config")
|
||||
.arg("--tools")
|
||||
.arg(mcp::builtin_tools_arg())
|
||||
.arg("--allowedTools")
|
||||
.arg(mcp::allowed_tools_arg(files.flavor));
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(prompt.as_bytes()).await?;
|
||||
stdin.shutdown().await.ok();
|
||||
drop(stdin);
|
||||
}
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
|
||||
let prompt_too_long = Arc::new(AtomicBool::new(false));
|
||||
let rate_limited = Arc::new(AtomicBool::new(false));
|
||||
let auth_failed = Arc::new(AtomicBool::new(false));
|
||||
let flag_out = prompt_too_long.clone();
|
||||
let flag_err = prompt_too_long.clone();
|
||||
let rate_out = rate_limited.clone();
|
||||
let rate_err = rate_limited.clone();
|
||||
let auth_out = auth_failed.clone();
|
||||
let auth_err = auth_failed.clone();
|
||||
let bus_out = bus.clone();
|
||||
let bus_err = bus.clone();
|
||||
let pump_stdout = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
// Track usage as the turn unfolds. `last_inference` overwrites on
|
||||
// every assistant event so at result-time it holds the most recent
|
||||
// model call's usage — the actual context size. The `result` event
|
||||
// carries the cumulative-across-the-turn usage (cost signal). Both
|
||||
// get handed to `record_turn_usage` together so a single SSE
|
||||
// event updates both badges.
|
||||
let mut last_inference: Option<crate::events::TokenUsage> = None;
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
if line.contains(PROMPT_TOO_LONG_MARKER) {
|
||||
flag_out.store(true, Ordering::Relaxed);
|
||||
}
|
||||
// Auth-fail check happens on the raw line first so we
|
||||
// catch both the `api_retry` JSON events (which can land
|
||||
// before they're fully parseable) and any stderr-shaped
|
||||
// text that snuck onto stdout.
|
||||
if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) {
|
||||
auth_out.store(true, Ordering::Relaxed);
|
||||
}
|
||||
match serde_json::from_str::<serde_json::Value>(&line) {
|
||||
Ok(v) => {
|
||||
// Rate-limit detection: only fire on JSON `error` events,
|
||||
// not on arbitrary text content. An agent discussing a past
|
||||
// rate limit in its response would otherwise trigger a false
|
||||
// positive (the full conversation flows through stdout as
|
||||
// stream-json, so any text the model outputs is visible here).
|
||||
if v.get("type").and_then(|t| t.as_str()) == Some("error") {
|
||||
let raw = v.to_string();
|
||||
if RATE_LIMIT_MARKERS.iter().any(|m| raw.contains(m)) {
|
||||
rate_out.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
if let Some(u) = crate::events::TokenUsage::from_assistant_event(&v) {
|
||||
last_inference = Some(u);
|
||||
}
|
||||
if let Some(cost) = crate::events::TokenUsage::from_stream_event(&v) {
|
||||
// Fallback to `cost` if the turn somehow produced
|
||||
// a result without any assistant event — keeps the
|
||||
// ctx badge from going stale on a degenerate turn.
|
||||
let ctx = last_inference.unwrap_or(cost);
|
||||
bus_out.record_turn_usage(ctx, cost);
|
||||
}
|
||||
// Seed the API-reported context-window from the result
|
||||
// event's `modelUsage.*.contextWindow` field. This is
|
||||
// the authoritative per-inference active window used for
|
||||
// compaction watermarks — it reflects what the model
|
||||
// actually enforces, which may differ from the Nix
|
||||
// config (e.g. 200k active window on a 1M cache model).
|
||||
if let Some(w) =
|
||||
crate::events::TokenUsage::context_window_from_result_event(&v)
|
||||
{
|
||||
bus_out.set_api_context_window(w);
|
||||
}
|
||||
bus_out.observe_stream(&v);
|
||||
bus_out.emit(LiveEvent::Stream(v));
|
||||
}
|
||||
Err(_) => {
|
||||
// Non-JSON stdout: raw text check is fine here since these
|
||||
// are claude CLI messages, not conversation content.
|
||||
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
|
||||
rate_out.store(true, Ordering::Relaxed);
|
||||
}
|
||||
bus_out.emit(LiveEvent::Note {
|
||||
text: format!("(non-json) {line}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let stderr_tail: Arc<Mutex<VecDeque<String>>> =
|
||||
Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_TAIL_LINES)));
|
||||
let tail_clone = stderr_tail.clone();
|
||||
let pump_stderr = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
if line.contains(PROMPT_TOO_LONG_MARKER) {
|
||||
flag_err.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
|
||||
rate_err.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) {
|
||||
auth_err.store(true, Ordering::Relaxed);
|
||||
}
|
||||
// Mirror to journald so post-mortems work without the web UI
|
||||
// or the events sqlite. The bus event is what the dashboard
|
||||
// renders; the tracing line is what `journalctl -M <c> -b`
|
||||
// surfaces when claude exits non-zero.
|
||||
tracing::warn!(line = %line, "claude stderr");
|
||||
bus_err.emit(LiveEvent::Note {
|
||||
text: format!("stderr: {line}"),
|
||||
});
|
||||
let mut t = tail_clone.lock().unwrap();
|
||||
if t.len() >= STDERR_TAIL_LINES {
|
||||
t.pop_front();
|
||||
}
|
||||
t.push_back(line);
|
||||
}
|
||||
});
|
||||
|
||||
let status = child.wait().await?;
|
||||
let _ = pump_stdout.await;
|
||||
let _ = pump_stderr.await;
|
||||
let too_long = prompt_too_long.load(Ordering::Relaxed);
|
||||
let is_rate_limited = rate_limited.load(Ordering::Relaxed);
|
||||
let is_auth_failed = auth_failed.load(Ordering::Relaxed);
|
||||
if !status.success() && !too_long && !is_rate_limited && !is_auth_failed {
|
||||
let tail = stderr_tail.lock().unwrap();
|
||||
if tail.is_empty() {
|
||||
bail!("claude exited {status} (no stderr)");
|
||||
}
|
||||
let tail_str = tail.iter().cloned().collect::<Vec<_>>().join("\n");
|
||||
bail!("claude exited {status}\nstderr tail:\n{tail_str}");
|
||||
}
|
||||
Ok((too_long, is_rate_limited, is_auth_failed))
|
||||
}
|
||||
266
hive-ag3nt/src/turn_stats.rs
Normal file
266
hive-ag3nt/src/turn_stats.rs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
//! Per-turn analytics sink. One sqlite row per claude turn captures:
|
||||
//! identity (`model`, `wake_from`, `result_kind`), timing (`started_at`,
|
||||
//! `ended_at`, `duration_ms`), cost (token counts), behaviour (tool-call
|
||||
//! count + per-tool breakdown), and post-turn snapshot metrics
|
||||
//! (`open_threads_count`, `open_reminders_count`).
|
||||
//!
|
||||
//! Lives next to `hyperhive-events.sqlite` in the agent's state dir
|
||||
//! so the host-side state vacuum sweep can reach both. Schema is
|
||||
//! intentionally append-only — every column has a default so future
|
||||
//! additions don't break old readers; new columns land via
|
||||
//! `ALTER TABLE ... ADD COLUMN ... DEFAULT ...` in the migration
|
||||
//! block.
|
||||
//!
|
||||
//! Writes are best-effort: a failed insert logs a warning and lets
|
||||
//! the turn loop continue. The next turn either succeeds or the
|
||||
//! operator sees the journal trail.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
/// SQL bootstrap. CREATE TABLE IF NOT EXISTS so first-boot agents
|
||||
/// and existing ones converge on the same shape. The base table is
|
||||
/// fresh-install only; additive migrations land via `MIGRATIONS`
|
||||
/// below as try-and-ignore ALTERs so existing dbs catch up.
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS turn_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
wake_from TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
tool_call_count INTEGER NOT NULL DEFAULT 0,
|
||||
tool_call_breakdown_json TEXT,
|
||||
open_threads_count INTEGER,
|
||||
open_reminders_count INTEGER,
|
||||
result_kind TEXT NOT NULL,
|
||||
note TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_turn_stats_started
|
||||
ON turn_stats (started_at DESC);
|
||||
";
|
||||
|
||||
/// Additive column migrations. Each runs unconditionally and ignores
|
||||
/// `duplicate column name` errors — sqlite < 3.35 lacks
|
||||
/// `ADD COLUMN IF NOT EXISTS`, so try-and-ignore is the portable path.
|
||||
/// New columns MUST carry a default so existing rows decode.
|
||||
const MIGRATIONS: &[&str] = &[
|
||||
"ALTER TABLE turn_stats ADD COLUMN last_input_tokens INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE turn_stats ADD COLUMN last_output_tokens INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE turn_stats ADD COLUMN last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE turn_stats ADD COLUMN last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0",
|
||||
];
|
||||
|
||||
/// One row to be inserted. `Option`-wrapped fields default to NULL
|
||||
/// when the harness couldn't gather them (e.g. socket roundtrip for
|
||||
/// `open_threads` failed) so a partial row beats no row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TurnStatRow {
|
||||
pub started_at: i64,
|
||||
pub ended_at: i64,
|
||||
pub duration_ms: i64,
|
||||
pub model: String,
|
||||
pub wake_from: String,
|
||||
/// Cumulative across every inference in the turn (cost signal).
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
/// Last inference's usage — the actual context size at turn end.
|
||||
pub last_input_tokens: u64,
|
||||
pub last_output_tokens: u64,
|
||||
pub last_cache_read_input_tokens: u64,
|
||||
pub last_cache_creation_input_tokens: u64,
|
||||
pub tool_call_count: u64,
|
||||
/// Per-tool breakdown as JSON: `{"Read":12,"Bash":3,...}`. None
|
||||
/// when no tools were called (saves a sqlite write of `"{}"`).
|
||||
pub tool_call_breakdown_json: Option<String>,
|
||||
pub open_threads_count: Option<u64>,
|
||||
pub open_reminders_count: Option<u64>,
|
||||
/// `"ok" | "failed" | "prompt_too_long"`.
|
||||
pub result_kind: &'static str,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Thin sqlite wrapper. Cloning is cheap (Arc-shared connection).
|
||||
#[derive(Clone)]
|
||||
pub struct TurnStats {
|
||||
inner: std::sync::Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl TurnStats {
|
||||
/// Open the per-agent stats db, creating the file + schema if
|
||||
/// missing. Returns `None` when the db can't be opened (read-only
|
||||
/// fs in tests, missing state dir) — the harness logs and
|
||||
/// continues without a sink rather than failing the turn loop.
|
||||
#[must_use]
|
||||
pub fn open_default() -> Option<Self> {
|
||||
let path = default_path();
|
||||
match Self::open(&path) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = ?e,
|
||||
path = %path.display(),
|
||||
"turn_stats: open failed; per-turn analytics disabled"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let conn = Connection::open(path)
|
||||
.with_context(|| format!("open turn_stats db {}", path.display()))?;
|
||||
conn.execute_batch(SCHEMA)
|
||||
.context("apply turn_stats schema")?;
|
||||
for stmt in MIGRATIONS {
|
||||
// Ignore "duplicate column name" — the migration already ran.
|
||||
// Any other error is logged but doesn't fail open() because the
|
||||
// base schema works and we'd rather keep the harness alive than
|
||||
// crash on an upgrade hiccup.
|
||||
if let Err(e) = conn.execute(stmt, []) {
|
||||
let msg = e.to_string();
|
||||
if !msg.contains("duplicate column name") {
|
||||
tracing::warn!(error = %msg, stmt, "turn_stats migration failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
inner: std::sync::Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert a row. Best-effort — logs + swallows errors so a sqlite
|
||||
/// hiccup (locked db, full disk) doesn't crash the harness.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn record(&self, row: &TurnStatRow) {
|
||||
let conn = self.inner.lock().unwrap();
|
||||
let res = conn.execute(
|
||||
"INSERT INTO turn_stats (
|
||||
started_at, ended_at, duration_ms, model, wake_from,
|
||||
input_tokens, output_tokens,
|
||||
cache_read_input_tokens, cache_creation_input_tokens,
|
||||
last_input_tokens, last_output_tokens,
|
||||
last_cache_read_input_tokens, last_cache_creation_input_tokens,
|
||||
tool_call_count, tool_call_breakdown_json,
|
||||
open_threads_count, open_reminders_count,
|
||||
result_kind, note
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5,
|
||||
?6, ?7,
|
||||
?8, ?9,
|
||||
?10, ?11,
|
||||
?12, ?13,
|
||||
?14, ?15,
|
||||
?16, ?17,
|
||||
?18, ?19
|
||||
)",
|
||||
params![
|
||||
row.started_at,
|
||||
row.ended_at,
|
||||
row.duration_ms,
|
||||
row.model,
|
||||
row.wake_from,
|
||||
i64::try_from(row.input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.output_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.cache_read_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.cache_creation_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.last_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.last_output_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.last_cache_read_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.last_cache_creation_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.tool_call_count).unwrap_or(i64::MAX),
|
||||
row.tool_call_breakdown_json,
|
||||
row.open_threads_count
|
||||
.map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
|
||||
row.open_reminders_count
|
||||
.map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
|
||||
row.result_kind,
|
||||
row.note,
|
||||
],
|
||||
);
|
||||
if let Err(e) = res {
|
||||
tracing::warn!(error = ?e, "turn_stats: insert failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Token counts from the most recently inserted row, if any.
|
||||
/// Returns `(ctx, cost)` — both backfill `Bus` on startup so the
|
||||
/// per-agent web UI's ctx + cost badges paint with real numbers on
|
||||
/// cold load instead of waiting for the next `TokenUsageChanged`
|
||||
/// SSE event. Best-effort: any sqlite error returns `(None, None)`.
|
||||
///
|
||||
/// Pre-migration rows (before the `last_*_tokens` columns existed)
|
||||
/// have last-inference zeros — those rows yield `ctx = None` so the
|
||||
/// badge stays empty until the next real turn rather than showing a
|
||||
/// misleading 0.
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn last_usage(
|
||||
&self,
|
||||
) -> (
|
||||
Option<crate::events::TokenUsage>,
|
||||
Option<crate::events::TokenUsage>,
|
||||
) {
|
||||
let conn = self.inner.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT input_tokens, output_tokens,
|
||||
cache_read_input_tokens, cache_creation_input_tokens,
|
||||
last_input_tokens, last_output_tokens,
|
||||
last_cache_read_input_tokens, last_cache_creation_input_tokens
|
||||
FROM turn_stats
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
let g = |i: usize| -> rusqlite::Result<u64> {
|
||||
Ok(u64::try_from(row.get::<_, i64>(i)?).unwrap_or(0))
|
||||
};
|
||||
let cost = crate::events::TokenUsage {
|
||||
input_tokens: g(0)?,
|
||||
output_tokens: g(1)?,
|
||||
cache_read_input_tokens: g(2)?,
|
||||
cache_creation_input_tokens: g(3)?,
|
||||
};
|
||||
let last = crate::events::TokenUsage {
|
||||
input_tokens: g(4)?,
|
||||
output_tokens: g(5)?,
|
||||
cache_read_input_tokens: g(6)?,
|
||||
cache_creation_input_tokens: g(7)?,
|
||||
};
|
||||
let ctx = if last == crate::events::TokenUsage::default() {
|
||||
None
|
||||
} else {
|
||||
Some(last)
|
||||
};
|
||||
Ok((ctx, Some(cost)))
|
||||
},
|
||||
)
|
||||
.unwrap_or((None, None))
|
||||
}
|
||||
}
|
||||
|
||||
fn default_path() -> PathBuf {
|
||||
crate::paths::state_dir().join("hyperhive-turn-stats.sqlite")
|
||||
}
|
||||
912
hive-ag3nt/src/web_ui.rs
Normal file
912
hive-ag3nt/src/web_ui.rs
Normal file
|
|
@ -0,0 +1,912 @@
|
|||
//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell;
|
||||
//! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page
|
||||
//! state as JSON; the JS app renders. Live events stream on
|
||||
//! `/events/stream`. Action POSTs (`/send`, `/login/*`) return either a
|
||||
//! 303 Redirect (for browsers that submit the form normally) or just
|
||||
//! 200 OK — the JS app re-fetches `/api/state` afterwards.
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
Form, Router,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{
|
||||
IntoResponse, Response,
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
},
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::client;
|
||||
use crate::events::Bus;
|
||||
use crate::login::LoginState;
|
||||
use crate::login_session::{LoginSession, drop_if_finished};
|
||||
use crate::mcp;
|
||||
use crate::turn::TurnFiles;
|
||||
|
||||
/// Live login state for the web UI. The harness updates this in place as it
|
||||
/// transitions between `NeedsLogin` and `Online`; the UI reads on each
|
||||
/// render.
|
||||
pub type LoginStateCell = Arc<Mutex<LoginState>>;
|
||||
|
||||
/// Shared turn lock. The serve loop acquires this (as an async mutex) for the
|
||||
/// duration of every `drive_turn` call. The `/api/compact` handler tries
|
||||
/// `try_lock()` and rejects immediately if a turn is in flight, preventing
|
||||
/// concurrent access to the claude session.
|
||||
pub type TurnLock = Arc<tokio::sync::Mutex<()>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
label: String,
|
||||
login: LoginStateCell,
|
||||
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
|
||||
bus: Bus,
|
||||
socket: PathBuf,
|
||||
/// Same `TurnFiles` the harness's turn loop uses. Shared so
|
||||
/// `/api/compact` re-uses the exact MCP config / system prompt /
|
||||
/// settings claude saw on the last regular turn — keeps the
|
||||
/// session shape identical across compact + normal turns.
|
||||
files: TurnFiles,
|
||||
/// Prevents `/api/compact` from racing with an in-flight normal turn.
|
||||
turn_lock: TurnLock,
|
||||
/// VNC port read from `/etc/hyperhive/gui.json` at startup.
|
||||
/// `None` when the file is absent (gui not enabled for this agent).
|
||||
gui_vnc_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn flavor(&self) -> Flavor {
|
||||
self.files.flavor
|
||||
}
|
||||
}
|
||||
|
||||
/// Which wire protocol the per-agent UI's `/send` handler should speak.
|
||||
/// Sub-agent → `AgentRequest::OperatorMsg`; manager →
|
||||
/// `ManagerRequest::OperatorMsg`. Reuses the MCP-side enum so a
|
||||
/// single value drives both the send protocol and (in
|
||||
/// `post_compact`) the allowed-tools surface claude sees.
|
||||
pub type Flavor = mcp::Flavor;
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the TCP listener cannot bind to the given port.
|
||||
pub async fn serve(
|
||||
label: String,
|
||||
port: u16,
|
||||
login: LoginStateCell,
|
||||
bus: Bus,
|
||||
socket: PathBuf,
|
||||
files: TurnFiles,
|
||||
turn_lock: TurnLock,
|
||||
) -> Result<()> {
|
||||
let gui_vnc_port = read_gui_json();
|
||||
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
||||
.map(PathBuf::from)
|
||||
.context(
|
||||
"HIVE_STATIC_DIR env var not set — point it at the merged \
|
||||
per-agent dist (see hyperhive.frontend.mergedDist in nix)",
|
||||
)?;
|
||||
if !static_dir.is_dir() {
|
||||
anyhow::bail!(
|
||||
"HIVE_STATIC_DIR ({}) is not a directory",
|
||||
static_dir.display()
|
||||
);
|
||||
}
|
||||
tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved");
|
||||
let state = AppState {
|
||||
label,
|
||||
login,
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
bus,
|
||||
socket,
|
||||
files,
|
||||
turn_lock,
|
||||
gui_vnc_port,
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/api/state", get(api_state))
|
||||
.route("/events/stream", get(events_stream))
|
||||
.route("/events/history", get(events_history))
|
||||
.route("/send", post(post_send))
|
||||
.route("/login/start", post(post_login_start))
|
||||
.route("/login/code", post(post_login_code))
|
||||
.route("/login/cancel", post(post_login_cancel))
|
||||
.route("/api/cancel", post(post_cancel_turn))
|
||||
.route("/api/compact", post(post_compact))
|
||||
.route("/api/model", post(post_set_model))
|
||||
.route("/api/new-session", post(post_new_session))
|
||||
.route("/api/loose-ends", get(api_loose_ends))
|
||||
.route("/api/stats", get(api_stats))
|
||||
.route("/screen/ws", get(screen_ws))
|
||||
.route("/icon", get(serve_icon))
|
||||
// Anything else (`/`, `/stats`, `/screen`, `/static/*`)
|
||||
// falls through to the merged dist. ServeDir auto-appends
|
||||
// `.html` when the URL is a bare path that matches a file
|
||||
// (so `/stats` → `dist/stats.html`, `/screen` → `dist/
|
||||
// screen.html`). Per-agent `extraFiles` additions are
|
||||
// already layered into this same directory (see
|
||||
// hyperhive.frontend.mergedDist in nix).
|
||||
.fallback_service(ServeDir::new(&static_dir))
|
||||
.with_state(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = bind_with_retry(addr, "web UI").await?;
|
||||
tracing::info!(%port, "web UI listening");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static assets + state snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bind a TCP listener with `SO_REUSEADDR` set, retrying on
|
||||
/// `AddrInUse` indefinitely with exponential backoff capped at 2s.
|
||||
/// nspawn restarts can race the previous harness's socket release;
|
||||
/// `SO_REUSEADDR` lets us reclaim a port still in `TIME_WAIT` from a
|
||||
/// clean previous exit, and the retry covers the case where the
|
||||
/// previous process is genuinely still alive (systemd restart-delay
|
||||
/// overlap).
|
||||
///
|
||||
/// The retry has no attempt cap: capping was the proximate cause of
|
||||
/// issue #324 — two back-to-back restarts left the previous socket
|
||||
/// holding the port for longer than the ~20s the old 12-attempt
|
||||
/// budget allowed, and the harness silently lost its web UI for the
|
||||
/// rest of the process lifetime. Genuine port collisions are
|
||||
/// preflighted host-side (`lifecycle::{spawn,rebuild}`) and surfaced
|
||||
/// on the dashboard as a banner, so at this layer a persistent
|
||||
/// `AddrInUse` always reflects a recoverable stale socket — retrying
|
||||
/// forever is the safe choice. The first attempts log at WARN; once
|
||||
/// we cross attempt 12 the level drops to INFO so a long stale
|
||||
/// socket doesn't flood the journal.
|
||||
async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result<tokio::net::TcpListener> {
|
||||
let mut delay_ms = 250u64;
|
||||
let mut attempts = 0u32;
|
||||
loop {
|
||||
match try_bind(addr) {
|
||||
Ok(l) => {
|
||||
if attempts > 0 {
|
||||
tracing::info!(
|
||||
%addr, attempts,
|
||||
"{label}: bind succeeded after retry"
|
||||
);
|
||||
}
|
||||
return Ok(l);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
|
||||
let attempt = attempts + 1;
|
||||
if attempt <= 12 {
|
||||
tracing::warn!(
|
||||
%addr, attempt,
|
||||
"{label}: AddrInUse, retrying in {delay_ms}ms"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
%addr, attempt,
|
||||
"{label}: AddrInUse still holding, retrying in {delay_ms}ms"
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
||||
attempts += 1;
|
||||
delay_ms = (delay_ms * 2).min(2000);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e).with_context(|| format!("bind {label} on {addr}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
|
||||
let sock = match addr {
|
||||
SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?,
|
||||
SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?,
|
||||
};
|
||||
sock.set_reuseaddr(true)?;
|
||||
sock.bind(addr)?;
|
||||
sock.listen(1024)
|
||||
}
|
||||
|
||||
/// This agent's icon. Serves the operator-configured SVG from
|
||||
/// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix
|
||||
/// option) when present, otherwise the bundled default hyperhive logo.
|
||||
/// Always returns an image, so consumers (dashboard, favicon) can hit
|
||||
/// `/icon` unconditionally without probing whether one is configured.
|
||||
async fn serve_icon() -> impl IntoResponse {
|
||||
const DEFAULT_ICON: &str = include_str!("../../branding/hyperhive.svg");
|
||||
let body = std::fs::read_to_string("/etc/hyperhive/icon.svg")
|
||||
.unwrap_or_else(|_| DEFAULT_ICON.to_string());
|
||||
([("content-type", "image/svg+xml")], body)
|
||||
}
|
||||
|
||||
/// Read `/etc/hyperhive/gui.json` and extract the `vnc_port` field.
|
||||
/// Returns `None` if the file is absent or unparseable — GUI not enabled.
|
||||
fn read_gui_json() -> Option<u16> {
|
||||
let text = std::fs::read_to_string("/etc/hyperhive/gui.json").ok()?;
|
||||
let val: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
val["vnc_port"].as_u64().and_then(|p| u16::try_from(p).ok())
|
||||
}
|
||||
|
||||
/// WebSocket handler: upgrade then pump bytes between the WS client and
|
||||
/// the VNC server on `127.0.0.1:<vnc_port>`. Returns 404 when gui is not
|
||||
/// enabled for this agent.
|
||||
async fn screen_ws(
|
||||
ws: axum::extract::ws::WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
) -> Response {
|
||||
let Some(vnc_port) = state.gui_vnc_port else {
|
||||
return (StatusCode::NOT_FOUND, "gui not enabled for this agent").into_response();
|
||||
};
|
||||
ws.on_upgrade(move |socket| relay_ws_vnc(socket, vnc_port))
|
||||
}
|
||||
|
||||
/// Pure byte pump: forwards raw bytes between the WebSocket client and
|
||||
/// the VNC TCP stream. Transparent to any RFB variant (plain, `VeNCrypt`).
|
||||
async fn relay_ws_vnc(socket: axum::extract::ws::WebSocket, vnc_port: u16) {
|
||||
// Import futures traits locally so they don't conflict with
|
||||
// tokio_stream::StreamExt used at module scope.
|
||||
use axum::extract::ws::Message;
|
||||
use futures_util::{SinkExt, StreamExt as _};
|
||||
|
||||
let addr = format!("127.0.0.1:{vnc_port}");
|
||||
let Ok(tcp) = tokio::net::TcpStream::connect(&addr).await else {
|
||||
tracing::warn!(%addr, "screen/ws: could not connect to VNC server");
|
||||
return;
|
||||
};
|
||||
let (mut tcp_rx, mut tcp_tx) = tcp.into_split();
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// WS → TCP
|
||||
let ws_to_tcp = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = futures_util::StreamExt::next(&mut ws_rx).await {
|
||||
match msg {
|
||||
Message::Binary(data) => {
|
||||
if tcp_tx.write_all(&data).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {} // ping/pong/text: ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// TCP → WS
|
||||
let tcp_to_ws = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match tcp_rx.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if ws_tx
|
||||
.send(Message::Binary(buf[..n].to_vec().into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either direction to close, then let both tasks drop.
|
||||
tokio::select! {
|
||||
_ = ws_to_tcp => {}
|
||||
_ = tcp_to_ws => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StatsQuery {
|
||||
window: Option<String>,
|
||||
}
|
||||
|
||||
async fn api_stats(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Query(q): axum::extract::Query<StatsQuery>,
|
||||
) -> axum::Json<crate::stats::Snapshot> {
|
||||
let window = crate::stats::Window::parse(q.window.as_deref().unwrap_or("24h"));
|
||||
let mut snapshot = crate::stats::snapshot_default(window);
|
||||
// Pass the window span to the reminder-stats RPC so the broker
|
||||
// filters its counts to the same time range as the chart data.
|
||||
let window_secs = window.span_secs();
|
||||
let window_secs_u = u64::try_from(window_secs).unwrap_or(0);
|
||||
snapshot.reminder_stats = fetch_reminder_stats(&state.socket, state.flavor(), window_secs_u).await;
|
||||
axum::Json(snapshot)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StateSnapshot {
|
||||
/// Bus seq at the moment this snapshot was assembled. Clients dedupe
|
||||
/// their buffered SSE traffic against this value: events with
|
||||
/// `seq <= snapshot.seq` are already reflected (or pre-date the
|
||||
/// snapshot); `seq > snapshot.seq` is post-snapshot. Reset to 0 on
|
||||
/// harness restart — clients treat reconnect as a fresh world.
|
||||
seq: u64,
|
||||
label: String,
|
||||
dashboard_port: u16,
|
||||
/// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`.
|
||||
status: &'static str,
|
||||
/// Present when `status == "needs_login_in_progress"`.
|
||||
session: Option<SessionView>,
|
||||
/// Last N messages addressed to this agent, newest-first. Pulled
|
||||
/// from the broker via the per-agent socket on each render.
|
||||
/// Empty on transport failure.
|
||||
inbox: Vec<hive_sh4re::InboxRow>,
|
||||
/// Authoritative turn-loop state from the harness and the unix
|
||||
/// timestamp the state was entered. The JS computes the age
|
||||
/// client-side off this rather than tracking it from SSE events.
|
||||
turn_state: crate::events::TurnState,
|
||||
turn_state_since: i64,
|
||||
/// Currently-active claude model name. Reflected on the page so
|
||||
/// the operator can see what they just switched to (and what's
|
||||
/// in flight). Mutable at runtime via `POST /api/model`.
|
||||
model: String,
|
||||
/// Effective context-window token budget for the current model.
|
||||
/// Primary source: API-reported `modelUsage.*.contextWindow` from
|
||||
/// the last result event (authoritative per-inference active window).
|
||||
/// Falls back to `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars, then 200 000.
|
||||
/// Consumers (e.g. dashboard badge) use this to render ctx-usage %.
|
||||
context_window_tokens: u64,
|
||||
/// Last-inference token usage from the most recent completed
|
||||
/// turn — represents the current context-window size at turn-end.
|
||||
/// `null` until the first turn finishes.
|
||||
ctx_usage: Option<crate::events::TokenUsage>,
|
||||
/// Cumulative token usage across the most recent turn's inferences
|
||||
/// (cost signal). `null` until the first turn finishes.
|
||||
cost_usage: Option<crate::events::TokenUsage>,
|
||||
/// Navigation links for this agent page (issue #262). Stats is
|
||||
/// always present; screen when the VNC compositor is enabled; the
|
||||
/// forge profile + the agent-configs mirror repo when the agent
|
||||
/// has a forge account; followed by any agent-declared
|
||||
/// `hyperhive.dashboardLinks` extras (read from
|
||||
/// `{state_dir}/hyperhive-dashboard-links.json`). Each URL is
|
||||
/// already absolute — built server-side from the request `Host`
|
||||
/// header — so the frontend just renders.
|
||||
///
|
||||
/// This same list is the **source of truth** for the per-agent
|
||||
/// page *and* the dashboard card's icon-only nav strip: hive-c0re
|
||||
/// proxies it via `GET /api/agent/{name}/links` (same-origin from
|
||||
/// the dashboard JS), avoiding CORS and centralising the link
|
||||
/// definitions in the agent backend.
|
||||
links: Vec<AgentLink>,
|
||||
}
|
||||
|
||||
/// One navigation link in the agent page header row. Same JSON
|
||||
/// shape feeds the dashboard's icon-only nav strip via the host's
|
||||
/// `GET /api/agent/{name}/links` passthrough proxy, so the agent
|
||||
/// backend is the single source of truth for what links an agent
|
||||
/// exposes (issue #262).
|
||||
#[derive(Serialize)]
|
||||
struct AgentLink {
|
||||
/// `kind = Container | Forge` → path; `kind = External` → full URL.
|
||||
/// The frontend prepends the right base before rendering.
|
||||
url: String,
|
||||
icon: String,
|
||||
label: String,
|
||||
kind: AgentLinkKind,
|
||||
}
|
||||
|
||||
/// Resolution hint for `AgentLink.url`. The agent backend can't know
|
||||
/// which hostname the browser sees (especially when the dashboard
|
||||
/// proxies the call from a different origin), so it labels each link
|
||||
/// and lets the frontend prepend the right base.
|
||||
#[derive(Serialize, Clone, Copy)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum AgentLinkKind {
|
||||
/// `url` is a path on the agent's container web UI (`/stats`,
|
||||
/// `/screen`). Agent page: same-origin path. Dashboard:
|
||||
/// `http://<host>:<container.port><url>`.
|
||||
Container,
|
||||
/// `url` is a path on the local Forgejo (`/<label>`,
|
||||
/// `/agent-configs/<label>`). Both surfaces:
|
||||
/// `http://<host>:3000<url>`.
|
||||
Forge,
|
||||
/// `url` is already a fully-qualified absolute URL — use as-is.
|
||||
/// Agent-declared `hyperhive.dashboardLinks` extras arrive here.
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionView {
|
||||
/// First `https://…` claude emitted on stdout, if any.
|
||||
url: Option<String>,
|
||||
/// Accumulated stdout + stderr.
|
||||
output: String,
|
||||
finished: bool,
|
||||
exit_note: Option<String>,
|
||||
}
|
||||
|
||||
/// Proxy this agent's loose-ends list via the per-agent socket. The
|
||||
/// web UI surfaces the result as a collapsible section in the page
|
||||
/// so the operator can see at a glance what's pending against the
|
||||
/// agent (questions asked by it, peer questions targeting it,
|
||||
/// reminders it scheduled, approvals for the manager). Same data
|
||||
/// the `mcp__hyperhive__get_loose_ends` tool sees from inside the
|
||||
/// container.
|
||||
async fn api_loose_ends(State(state): State<AppState>) -> Response {
|
||||
let loose_ends: Vec<hive_sh4re::LooseEnd> = match state.flavor() {
|
||||
Flavor::Agent => {
|
||||
match client::request::<_, hive_sh4re::AgentResponse>(
|
||||
&state.socket,
|
||||
&hive_sh4re::AgentRequest::GetLooseEnds,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hive_sh4re::AgentResponse::LooseEnds { loose_ends }) => loose_ends,
|
||||
Ok(hive_sh4re::AgentResponse::Err { message }) => {
|
||||
return error_response(&format!("get_loose_ends: {message}"));
|
||||
}
|
||||
Ok(other) => return error_response(&format!("unexpected response: {other:?}")),
|
||||
Err(e) => return error_response(&format!("transport: {e:#}")),
|
||||
}
|
||||
}
|
||||
Flavor::Manager => {
|
||||
match client::request::<_, hive_sh4re::ManagerResponse>(
|
||||
&state.socket,
|
||||
// Manager's own loose ends — the web page is the
|
||||
// manager's page, not a hive-wide console.
|
||||
&hive_sh4re::ManagerRequest::GetLooseEnds { agent: None },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hive_sh4re::ManagerResponse::LooseEnds { loose_ends }) => loose_ends,
|
||||
Ok(hive_sh4re::ManagerResponse::Err { message }) => {
|
||||
return error_response(&format!("get_loose_ends: {message}"));
|
||||
}
|
||||
Ok(other) => return error_response(&format!("unexpected response: {other:?}")),
|
||||
Err(e) => return error_response(&format!("transport: {e:#}")),
|
||||
}
|
||||
}
|
||||
};
|
||||
axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response()
|
||||
}
|
||||
|
||||
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||
// Capture seq *before* any reads so the dedupe contract is
|
||||
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
||||
let seq = state.bus.current_seq();
|
||||
drop_if_finished(&state.session);
|
||||
let login = *state.login.lock().unwrap();
|
||||
let session_snapshot = state.session.lock().unwrap().clone();
|
||||
let (status, session_view) = match (login, session_snapshot) {
|
||||
(LoginState::Online, _) if state.bus.is_rate_limited() => ("rate_limited", None),
|
||||
(LoginState::Online, _) => ("online", None),
|
||||
(LoginState::NeedsLogin, None) => ("needs_login_idle", None),
|
||||
(LoginState::NeedsLogin, Some(s)) => (
|
||||
"needs_login_in_progress",
|
||||
Some(SessionView {
|
||||
url: s.url(),
|
||||
output: s.output(),
|
||||
finished: s.finished(),
|
||||
exit_note: s.exit_note(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(7000);
|
||||
let inbox = recent_inbox(&state.socket, state.flavor()).await;
|
||||
let (turn_state, turn_state_since) = state.bus.state_snapshot();
|
||||
let model = state.bus.model();
|
||||
let context_window_tokens = state
|
||||
.bus
|
||||
.api_context_window()
|
||||
.unwrap_or_else(|| crate::events::context_window_tokens(&model));
|
||||
let ctx_usage = state.bus.last_ctx_usage();
|
||||
let cost_usage = state.bus.last_cost_usage();
|
||||
axum::Json(StateSnapshot {
|
||||
seq,
|
||||
label: state.label.clone(),
|
||||
dashboard_port,
|
||||
status,
|
||||
session: session_view,
|
||||
inbox,
|
||||
turn_state,
|
||||
turn_state_since,
|
||||
model,
|
||||
context_window_tokens,
|
||||
ctx_usage,
|
||||
cost_usage,
|
||||
links: agent_links(&state.label, state.gui_vnc_port.is_some()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the navigation link list for the agent page header
|
||||
/// (issue #262). Single source of truth: the dashboard's icon-only
|
||||
/// nav strip consumes the same list via the host's
|
||||
/// `GET /api/agent/{name}/links` proxy. URLs are paths (relative)
|
||||
/// for Container/Forge targets and absolute for External; the
|
||||
/// frontend resolves each against its `kind` so the backend never
|
||||
/// has to guess the operator's browser host.
|
||||
///
|
||||
/// The agent harness doesn't know its own deployed sha (the meta
|
||||
/// flake lock lives on the host), so the `config` link points at
|
||||
/// the repo root; the dashboard renders a `deployed:<sha>` chip
|
||||
/// alongside the strip so the operator still sees what's live.
|
||||
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
||||
let mut links = Vec::new();
|
||||
|
||||
// Note: the URLs are the actual HTML files served out of the
|
||||
// frontend dist (`stats.html` / `screen.html`); after the #273
|
||||
// backend/frontend split the harness serves these as static
|
||||
// files via ServeDir rather than via Rust routes, so the URL
|
||||
// has to be the on-disk filename.
|
||||
links.push(AgentLink {
|
||||
url: "/stats.html".to_owned(),
|
||||
icon: "📊".to_owned(),
|
||||
label: "stats".to_owned(),
|
||||
kind: AgentLinkKind::Container,
|
||||
});
|
||||
|
||||
if gui_enabled {
|
||||
links.push(AgentLink {
|
||||
url: "/screen.html".to_owned(),
|
||||
icon: "🖥".to_owned(),
|
||||
label: "screen".to_owned(),
|
||||
kind: AgentLinkKind::Container,
|
||||
});
|
||||
}
|
||||
|
||||
if crate::paths::state_dir().join("forge-token").is_file() {
|
||||
links.push(AgentLink {
|
||||
url: format!("/{label}"),
|
||||
icon: "⬡".to_owned(),
|
||||
label: "forge".to_owned(),
|
||||
kind: AgentLinkKind::Forge,
|
||||
});
|
||||
links.push(AgentLink {
|
||||
url: format!("/agent-configs/{label}"),
|
||||
icon: "↳".to_owned(),
|
||||
label: "config".to_owned(),
|
||||
kind: AgentLinkKind::Forge,
|
||||
});
|
||||
}
|
||||
|
||||
// Agent-declared extras (`hyperhive.dashboardLinks` → the
|
||||
// `hive-dashboard-links` NixOS oneshot writes them to
|
||||
// `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk
|
||||
// is `{label, icon, url}` with absolute URLs — those become
|
||||
// `kind = External` links, passed through verbatim.
|
||||
let extras_path =
|
||||
crate::paths::state_dir().join("hyperhive-dashboard-links.json");
|
||||
if let Ok(text) = std::fs::read_to_string(&extras_path)
|
||||
&& !text.trim().is_empty()
|
||||
&& let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text)
|
||||
{
|
||||
for e in extras {
|
||||
links.push(AgentLink {
|
||||
url: e.url,
|
||||
icon: e.icon,
|
||||
label: e.label,
|
||||
kind: AgentLinkKind::External,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
links
|
||||
}
|
||||
|
||||
/// On-disk shape of `hyperhive-dashboard-links.json` (the
|
||||
/// `hive-dashboard-links` NixOS oneshot's output). Mapped to
|
||||
/// `AgentLink { kind: External }` inside `agent_links`.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ExtraLink {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Best-effort: pull the last 30 messages addressed to us via the
|
||||
/// per-agent / manager socket. Empty list on any transport / decode
|
||||
/// failure — the inbox section is decorative, not authoritative.
|
||||
async fn recent_inbox(socket: &std::path::Path, flavor: Flavor) -> Vec<hive_sh4re::InboxRow> {
|
||||
const LIMIT: u64 = 30;
|
||||
match flavor {
|
||||
Flavor::Agent => {
|
||||
match client::request::<_, hive_sh4re::AgentResponse>(
|
||||
socket,
|
||||
&hive_sh4re::AgentRequest::Recent { limit: LIMIT },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hive_sh4re::AgentResponse::Recent { rows }) => rows,
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
Flavor::Manager => {
|
||||
match client::request::<_, hive_sh4re::ManagerResponse>(
|
||||
socket,
|
||||
&hive_sh4re::ManagerRequest::Recent { limit: LIMIT },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hive_sh4re::ManagerResponse::Recent { rows }) => rows,
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch reminder activity stats from the broker via the per-agent /
|
||||
/// manager socket. Returns None on any transport / decode failure — the
|
||||
/// stats are decorative, not authoritative.
|
||||
async fn fetch_reminder_stats(socket: &std::path::Path, flavor: Flavor, window_secs: u64) -> Option<hive_sh4re::ReminderStats> {
|
||||
match flavor {
|
||||
Flavor::Agent => {
|
||||
match client::request::<_, hive_sh4re::AgentResponse>(
|
||||
socket,
|
||||
&hive_sh4re::AgentRequest::ReminderRollup {
|
||||
since_secs: window_secs,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hive_sh4re::AgentResponse::ReminderRollup(stats)) => Some(stats),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Flavor::Manager => {
|
||||
match client::request::<_, hive_sh4re::ManagerResponse>(
|
||||
socket,
|
||||
&hive_sh4re::ManagerRequest::ReminderRollup {
|
||||
since_secs: window_secs,
|
||||
// Manager's own stats page — its own reminders.
|
||||
agent: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hive_sh4re::ManagerResponse::ReminderRollup(stats)) => Some(stats),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendForm {
|
||||
body: String,
|
||||
}
|
||||
|
||||
async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) -> Response {
|
||||
let body = form.body.trim().to_owned();
|
||||
if body.is_empty() {
|
||||
return error_response("send: `body` required");
|
||||
}
|
||||
let result = match state.flavor() {
|
||||
Flavor::Agent => match client::request::<_, hive_sh4re::AgentResponse>(
|
||||
&state.socket,
|
||||
&hive_sh4re::AgentRequest::OperatorMsg { body },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hive_sh4re::AgentResponse::Ok) => Ok(()),
|
||||
Ok(hive_sh4re::AgentResponse::Err { message }) => Err(message),
|
||||
Ok(other) => Err(format!("unexpected response: {other:?}")),
|
||||
Err(e) => Err(format!("transport: {e:#}")),
|
||||
},
|
||||
Flavor::Manager => match client::request::<_, hive_sh4re::ManagerResponse>(
|
||||
&state.socket,
|
||||
&hive_sh4re::ManagerRequest::OperatorMsg { body },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hive_sh4re::ManagerResponse::Ok) => Ok(()),
|
||||
Ok(hive_sh4re::ManagerResponse::Err { message }) => Err(message),
|
||||
Ok(other) => Err(format!("unexpected response: {other:?}")),
|
||||
Err(e) => Err(format!("transport: {e:#}")),
|
||||
},
|
||||
};
|
||||
match result {
|
||||
// 200 instead of 303 → the client doesn't refetch /api/state.
|
||||
// The operator message becomes a broker `Sent` (already shown
|
||||
// server-side in the dashboard); on the agent side, the
|
||||
// resulting `TurnStart` SSE event drives the terminal + the
|
||||
// inbox row gets consumed by the time `TurnEnd` fires the
|
||||
// existing turn-end refresh.
|
||||
Ok(()) => (axum::http::StatusCode::OK, "ok").into_response(),
|
||||
Err(e) => error_response(&format!("send failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn events_history(State(state): State<AppState>) -> axum::Json<serde_json::Value> {
|
||||
// Capture seq *before* the read so dedupe is "drop buffered events
|
||||
// you've already seen in history", never "lose an event that fired
|
||||
// between the read and the timestamp." Historical rows have no
|
||||
// per-row seq; only the high-water mark matters for the dedupe
|
||||
// window.
|
||||
let seq = state.bus.current_seq();
|
||||
let events = state.bus.history();
|
||||
axum::Json(serde_json::json!({ "seq": seq, "events": events }))
|
||||
}
|
||||
|
||||
async fn events_stream(
|
||||
State(state): State<AppState>,
|
||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
tracing::info!("sse: client subscribed");
|
||||
let rx = state.bus.subscribe();
|
||||
// Drop a "hello" note into the bus so every new subscriber sees at
|
||||
// least one event immediately and can clear the connecting placeholder.
|
||||
state.bus.emit(crate::events::LiveEvent::Note {
|
||||
text: "live stream attached".into(),
|
||||
});
|
||||
let stream = BroadcastStream::new(rx).filter_map(|res| {
|
||||
let ev = res.ok()?;
|
||||
let json = serde_json::to_string(&ev).ok()?;
|
||||
Some(Ok(Event::default().data(json)))
|
||||
});
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
async fn post_login_start(State(state): State<AppState>) -> Response {
|
||||
drop_if_finished(&state.session);
|
||||
{
|
||||
let guard = state.session.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return (axum::http::StatusCode::OK, "ok").into_response();
|
||||
}
|
||||
}
|
||||
match LoginSession::start() {
|
||||
Ok(session) => {
|
||||
*state.session.lock().unwrap() = Some(Arc::new(session));
|
||||
// Flip status from needs_login_idle → needs_login_in_progress
|
||||
// so the web UI's badge + polling kick in (polling is still
|
||||
// the right tool for the streaming session output during
|
||||
// the login flow itself; events drop the poll for
|
||||
// *everything else*).
|
||||
state.bus.emit_status("needs_login_in_progress");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("login start failed: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CodeForm {
|
||||
code: String,
|
||||
}
|
||||
|
||||
async fn post_login_code(State(state): State<AppState>, Form(form): Form<CodeForm>) -> Response {
|
||||
let session = state.session.lock().unwrap().clone();
|
||||
let Some(session) = session else {
|
||||
return error_response("no login session running");
|
||||
};
|
||||
if let Err(e) = session.submit_code(&form.code).await {
|
||||
return error_response(&format!("submit code failed: {e:#}"));
|
||||
}
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
async fn post_login_cancel(State(state): State<AppState>) -> Response {
|
||||
let session = state.session.lock().unwrap().take();
|
||||
if let Some(session) = session {
|
||||
session.close_stdin().await;
|
||||
session.kill();
|
||||
}
|
||||
// Back to needs_login_idle (LoginState unchanged, session gone).
|
||||
state.bus.emit_status("needs_login_idle");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
/// Operator-initiated session compaction. Spawns `turn::compact_session`
|
||||
/// in the background — the HTTP handler returns immediately so the
|
||||
/// async-form spinner can clear. Output (claude's compaction stream,
|
||||
/// the "/compact done" note) lands in the live event panel like any
|
||||
/// other turn. If a regular turn is in flight, claude's own session
|
||||
/// lock will reject this one and we surface the error as a Note.
|
||||
#[derive(Deserialize)]
|
||||
struct ModelForm {
|
||||
model: String,
|
||||
}
|
||||
|
||||
/// Switch the model for future turns. The current turn (if any)
|
||||
/// keeps its model; `/model <name>` applies starting with the next
|
||||
/// `recv` cycle. Empty / whitespace-only inputs are rejected. No
|
||||
/// claude-side validation — we just hand the string through to
|
||||
/// `claude --model <name>`; an unknown model surfaces as a turn
|
||||
/// failure in the live panel and the operator can revert.
|
||||
async fn post_set_model(State(state): State<AppState>, Form(form): Form<ModelForm>) -> Response {
|
||||
let name = form.model.trim();
|
||||
if name.is_empty() {
|
||||
return error_response("model: name required");
|
||||
}
|
||||
state.bus.set_model(name);
|
||||
state.bus.emit(crate::events::LiveEvent::Note {
|
||||
text: format!("operator: /model — claude model set to '{name}' for future turns"),
|
||||
});
|
||||
tracing::info!(%name, "operator set model");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
async fn post_compact(State(state): State<AppState>) -> Response {
|
||||
// Clone the Arc before locking so the guard's lifetime is tied to the
|
||||
// clone (which we can move into the spawn) rather than to `state`.
|
||||
let lock = state.turn_lock.clone();
|
||||
// Reject immediately if a normal turn is in flight — concurrent access
|
||||
// to the claude session is unsafe and produces garbled output.
|
||||
let Ok(guard) = lock.try_lock_owned() else {
|
||||
return error_response("turn in flight — wait for it to finish before compacting");
|
||||
};
|
||||
let bus = state.bus.clone();
|
||||
let files = state.files.clone();
|
||||
tokio::spawn(async move {
|
||||
let _guard = guard; // keep lock alive for the duration of compaction
|
||||
bus.emit(crate::events::LiveEvent::Note {
|
||||
text: "operator: /compact — running on persistent session".into(),
|
||||
});
|
||||
bus.set_state(crate::events::TurnState::Compacting);
|
||||
let outcome = crate::turn::compact_session(&files, &bus).await;
|
||||
bus.set_state(crate::events::TurnState::Idle);
|
||||
// Best-effort manual /compact from the operator: compact_session
|
||||
// already emits a Note per outcome, so we don't need to re-emit
|
||||
// here — just record any underlying error to the harness log.
|
||||
if let crate::turn::TurnOutcome::Failed(e) = outcome {
|
||||
tracing::warn!(error = %format!("{e:#}"), "operator /compact failed");
|
||||
}
|
||||
});
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
/// Cancel the in-flight claude turn. Coarse-grained: shells out
|
||||
/// `pkill -INT claude` since there's at most one claude per container.
|
||||
/// SIGINT (not SIGTERM) so claude flushes anything in-flight and emits a
|
||||
/// final result row. Emits a Note so the operator sees the cancel
|
||||
/// landed; the actual state transition back to `idle` happens when
|
||||
/// `run_claude` wakes up and the harness emits `TurnEnd`.
|
||||
/// Arm a one-shot: the next claude turn drops `--continue`, starting a
|
||||
/// fresh session. Subsequent turns resume normal `--continue`
|
||||
/// behavior. Idempotent before the next turn fires — calling twice
|
||||
/// still results in a single fresh start. Useful when the
|
||||
/// session-resume context is poisoned (claude went off the rails,
|
||||
/// hit an unrecoverable refusal, etc.) and a full reset is cheaper
|
||||
/// than asking claude to forget mid-stream.
|
||||
async fn post_new_session(State(state): State<AppState>) -> Response {
|
||||
state.bus.request_new_session();
|
||||
state.bus.emit(crate::events::LiveEvent::Note {
|
||||
text: "operator: new session armed — next turn runs without --continue".into(),
|
||||
});
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
async fn post_cancel_turn(State(state): State<AppState>) -> Response {
|
||||
let out = tokio::process::Command::new("pkill")
|
||||
.args(["-INT", "claude"])
|
||||
.output()
|
||||
.await;
|
||||
let note = match out {
|
||||
Ok(o) if o.status.success() => "operator: /cancel — sent SIGINT to claude".to_owned(),
|
||||
Ok(o) if o.status.code() == Some(1) => {
|
||||
"operator: /cancel — no claude process to interrupt".to_owned()
|
||||
}
|
||||
Ok(o) => format!(
|
||||
"operator: /cancel — pkill exited {} stderr={}",
|
||||
o.status,
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => format!("operator: /cancel — pkill failed: {e}"),
|
||||
};
|
||||
state.bus.emit(crate::events::LiveEvent::Note { text: note });
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
fn error_response(message: &str) -> Response {
|
||||
// Plain text — JS app surfaces in `alert()`, HTML wrapping would just
|
||||
// be noise.
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
|
||||
}
|
||||
33
hive-c0re/Cargo.toml
Normal file
33
hive-c0re/Cargo.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
[package]
|
||||
name = "hive-c0re"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
# Render branding/agent-configs.svg → $OUT_DIR/agent-configs.png at
|
||||
# compile time (#424). build.rs shells out to `rsvg-convert`
|
||||
# (librsvg, pulled in via flake.nix' naersk nativeBuildInputs); the
|
||||
# baked PNG is included via include_bytes! from forge.rs so no
|
||||
# raster gets checked into git.
|
||||
build = "build.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
base64.workspace = true
|
||||
reqwest.workspace = true
|
||||
clap.workspace = true
|
||||
hive-sh4re.workspace = true
|
||||
libc = "0.2"
|
||||
rusqlite.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
49
hive-c0re/build.rs
Normal file
49
hive-c0re/build.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! Render `branding/agent-configs.svg` → `$OUT_DIR/agent-configs.png`
|
||||
//! at compile time so the daemon can `include_bytes!` the PNG without
|
||||
//! checking the raster into git (#424 mara: "generate png on the fly
|
||||
//! or in build"). The SVG is the source of truth; the PNG is a build
|
||||
//! artifact.
|
||||
//!
|
||||
//! Uses `rsvg-convert` from PATH (librsvg, already available in
|
||||
//! nixpkgs and added to the naersk derivation's `nativeBuildInputs`
|
||||
//! in `flake.nix`). For dev builds outside Nix, install librsvg via
|
||||
//! your system package manager (Debian/Ubuntu: `librsvg2-bin`,
|
||||
//! macOS: `brew install librsvg`).
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
const SVG_PATH: &str = "../branding/agent-configs.svg";
|
||||
const PNG_NAME: &str = "agent-configs.png";
|
||||
// 300×300 to match the existing branding/hyperhive.png, which the
|
||||
// Forgejo avatar endpoint accepts without resizing on upload.
|
||||
const PX: &str = "300";
|
||||
|
||||
fn main() {
|
||||
// Re-run the build script when either the SVG itself or this
|
||||
// script change. We deliberately don't watch every file in
|
||||
// `branding/` — only the one PNG we generate.
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed={SVG_PATH}");
|
||||
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set by cargo"));
|
||||
let png_path = out_dir.join(PNG_NAME);
|
||||
|
||||
let status = Command::new("rsvg-convert")
|
||||
.args(["--width", PX, "--height", PX, "-o"])
|
||||
.arg(&png_path)
|
||||
.arg(SVG_PATH)
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {}
|
||||
Ok(s) => panic!("rsvg-convert exited with {s} rendering {SVG_PATH}"),
|
||||
Err(e) => panic!(
|
||||
"failed to invoke rsvg-convert: {e}\n\
|
||||
install librsvg (Debian/Ubuntu: librsvg2-bin, macOS: brew install librsvg, \
|
||||
NixOS: pkgs.librsvg). The Nix derivation already pulls it in via \
|
||||
flake.nix → naersk-lib.buildPackage.nativeBuildInputs.",
|
||||
),
|
||||
}
|
||||
}
|
||||
778
hive-c0re/src/actions.rs
Normal file
778
hive-c0re/src/actions.rs
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
//! Operations that are exposed through more than one surface (the host admin
|
||||
//! socket *and* the dashboard's POST endpoints). Each function takes a
|
||||
//! `&Coordinator` and the request parameters; callers stitch the response
|
||||
//! shape they want (HTTP redirect vs JSON).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use hive_sh4re::{ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT};
|
||||
|
||||
use crate::coordinator::{Coordinator, TransientKind};
|
||||
use crate::lifecycle::{self, MANAGER_NAME};
|
||||
|
||||
/// Approve a pending request. Marks the approval row durably, then
|
||||
/// either runs the work inline (`InitConfig`, sub-second git ops) or
|
||||
/// enqueues it into `rebuild_queue` so the dashboard POST returns
|
||||
/// immediately while the long-running pipeline runs off-thread
|
||||
/// (closes #436 — operator no longer eats a 30-90s spinner on
|
||||
/// `ApplyCommit`).
|
||||
///
|
||||
/// Dispatch:
|
||||
/// - `ApplyCommit` → `QueueKind::Rebuild` (~30-90s wall time)
|
||||
/// - `UpdateMetaInputs` → `QueueKind::MetaUpdate` (~3-15s)
|
||||
/// - `Spawn` → `QueueKind::Spawn` (~30-90s)
|
||||
/// - `InitConfig` → inline (<1s; queue card would be noise)
|
||||
///
|
||||
/// The queue worker re-fetches the approval row on dispatch, runs
|
||||
/// the kind-specific pipeline, and fires `ApprovalResolved` /
|
||||
/// `Spawned` / `Rebuilt` / `ConfigReady` via `finish_approval`.
|
||||
pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
||||
let approval = coord.approvals.mark_approved(id)?;
|
||||
tracing::info!(
|
||||
%approval.id,
|
||||
%approval.agent,
|
||||
kind = ?approval.kind,
|
||||
%approval.commit_ref,
|
||||
"approval: dispatching",
|
||||
);
|
||||
match approval.kind {
|
||||
ApprovalKind::InitConfig => {
|
||||
// Sub-second git seed + forge-remote wire. Routing through
|
||||
// the queue would surface a queue card that's gone before
|
||||
// the operator's eyes refocus. Run inline.
|
||||
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
|
||||
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
|
||||
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
|
||||
run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await
|
||||
}
|
||||
ApprovalKind::ApplyCommit => {
|
||||
coord.rebuild_queue.enqueue_full(
|
||||
crate::rebuild_queue::QueueKind::Rebuild,
|
||||
approval.agent.clone(),
|
||||
crate::rebuild_queue::QueueSource::Approval,
|
||||
format!("approval #{id} apply commit"),
|
||||
None,
|
||||
Vec::new(),
|
||||
Some(id),
|
||||
);
|
||||
coord.emit_rebuild_queue_snapshot();
|
||||
Ok(())
|
||||
}
|
||||
ApprovalKind::UpdateMetaInputs => {
|
||||
// Inputs JSON-encoded into commit_ref by the manager's
|
||||
// submit path — surface them on the queue entry so the
|
||||
// dashboard can show *which* inputs are about to bump.
|
||||
let inputs: Vec<String> =
|
||||
serde_json::from_str(&approval.commit_ref).unwrap_or_default();
|
||||
coord.rebuild_queue.enqueue_full(
|
||||
crate::rebuild_queue::QueueKind::MetaUpdate,
|
||||
approval.agent.clone(),
|
||||
crate::rebuild_queue::QueueSource::Approval,
|
||||
format!("approval #{id} meta input update"),
|
||||
None,
|
||||
inputs,
|
||||
Some(id),
|
||||
);
|
||||
coord.emit_rebuild_queue_snapshot();
|
||||
Ok(())
|
||||
}
|
||||
ApprovalKind::Spawn => {
|
||||
coord.rebuild_queue.enqueue_full(
|
||||
crate::rebuild_queue::QueueKind::Spawn,
|
||||
approval.agent.clone(),
|
||||
crate::rebuild_queue::QueueSource::Approval,
|
||||
format!("approval #{id} spawn"),
|
||||
None,
|
||||
Vec::new(),
|
||||
Some(id),
|
||||
);
|
||||
coord.emit_rebuild_queue_snapshot();
|
||||
Ok(())
|
||||
}
|
||||
ApprovalKind::SchedulePrompt => {
|
||||
// No queue card for SchedulePrompt — the work is a single
|
||||
// sqlite insert, the actual "running" lifetime lives on
|
||||
// the scheduled-prompts surface itself (worker fires it
|
||||
// at the scheduled time). Run inline + fire
|
||||
// `ApprovalResolved` so the approval row leaves Pending
|
||||
// immediately.
|
||||
run_approval_schedule_prompt(&coord, approval).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker entry point for `ApprovalKind::ApplyCommit` queue entries.
|
||||
/// Re-fetches the approval row, runs the commit pipeline (same
|
||||
/// shape as the pre-#436 inline path), and fires `ApprovalResolved`
|
||||
/// + the lifecycle event (`Rebuilt` / `Spawned` for first-spawn).
|
||||
pub async fn run_approval_apply_commit(
|
||||
coord: &Arc<Coordinator>,
|
||||
queue_entry_id: Option<u64>,
|
||||
approval_id: i64,
|
||||
) -> Result<()> {
|
||||
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::ApplyCommit)?;
|
||||
let agent_dir = coord.ensure_runtime(&approval.agent)?;
|
||||
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
|
||||
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
|
||||
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
|
||||
coord.set_queue_step(queue_entry_id, "apply commit");
|
||||
let (result, terminal_tag, is_first_spawn) = run_apply_commit(
|
||||
coord,
|
||||
&approval,
|
||||
&agent_dir,
|
||||
&applied_dir,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
queue_entry_id,
|
||||
)
|
||||
.await;
|
||||
coord.set_queue_step(queue_entry_id, "forge push");
|
||||
if let Err(e) = crate::forge::push_config(&approval.agent).await {
|
||||
tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after apply failed");
|
||||
}
|
||||
if is_first_spawn && result.is_ok() {
|
||||
coord.set_queue_step(queue_entry_id, "first-spawn forge bootstrap");
|
||||
forge_after_first_spawn(coord, &approval.agent).await;
|
||||
}
|
||||
// `finish_approval` returns the original `result` so the queue
|
||||
// worker sees Ok/Err and marks the queue entry accordingly. The
|
||||
// approval row + helper events have already been fanned out.
|
||||
finish_approval(coord, &approval, result, terminal_tag, is_first_spawn)
|
||||
}
|
||||
|
||||
/// Inline (non-queued) handler for `ApprovalKind::SchedulePrompt`.
|
||||
/// On approve, decode the `SchedulePromptPayload` JSON from the
|
||||
/// approval's `commit_ref`, insert a row into `scheduled_prompts`
|
||||
/// (with `source = Approval { id }`), and fire `ApprovalResolved`.
|
||||
/// The worker takes over from here — fan-out at fire time.
|
||||
async fn run_approval_schedule_prompt(
|
||||
coord: &Coordinator,
|
||||
approval: hive_sh4re::Approval,
|
||||
) -> Result<()> {
|
||||
let result: Result<()> = async {
|
||||
let payload: hive_sh4re::SchedulePromptPayload =
|
||||
serde_json::from_str(&approval.commit_ref)
|
||||
.context("decode SchedulePromptPayload from approval.commit_ref")?;
|
||||
coord
|
||||
.scheduled_prompts
|
||||
.submit(crate::scheduled_prompts::NewSchedule {
|
||||
owner: approval.agent.clone(),
|
||||
targets: payload.targets,
|
||||
body: payload.body,
|
||||
first_fire_at_unix: payload.first_fire_at_unix,
|
||||
interval_seconds: payload.interval_seconds,
|
||||
description: payload.description,
|
||||
source: crate::scheduled_prompts::ScheduleSource::Approval {
|
||||
id: approval.id,
|
||||
},
|
||||
})
|
||||
.map(|_| ())
|
||||
.context("insert scheduled prompt")
|
||||
}
|
||||
.await;
|
||||
finish_approval(coord, &approval, result, None, false)
|
||||
}
|
||||
|
||||
/// Worker entry point for `ApprovalKind::UpdateMetaInputs` queue
|
||||
/// entries. Inputs come from the approval row's `commit_ref` field
|
||||
/// (JSON-encoded by the manager submit path), not the queue entry's
|
||||
/// `inputs` — the queue copy is for dashboard display only.
|
||||
pub async fn run_approval_update_meta_inputs(
|
||||
coord: &Arc<Coordinator>,
|
||||
queue_entry_id: Option<u64>,
|
||||
approval_id: i64,
|
||||
) -> Result<()> {
|
||||
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::UpdateMetaInputs)?;
|
||||
let inputs: Vec<String> = serde_json::from_str(&approval.commit_ref).unwrap_or_default();
|
||||
coord.set_queue_step(queue_entry_id, "nix flake update");
|
||||
let result = crate::meta::lock_update(&inputs).await;
|
||||
finish_approval(coord, &approval, result, None, false)
|
||||
}
|
||||
|
||||
/// Worker entry point for `ApprovalKind::Spawn` queue entries.
|
||||
/// Differs from `run_approval_apply_commit` only in routing through
|
||||
/// `lifecycle::spawn` (the deprecated direct-spawn path). Synchronous
|
||||
/// in the queue worker — the previous `tokio::spawn` wrapper is gone
|
||||
/// (the queue worker itself is the async task).
|
||||
pub async fn run_approval_spawn(
|
||||
coord: &Arc<Coordinator>,
|
||||
queue_entry_id: Option<u64>,
|
||||
approval_id: i64,
|
||||
) -> Result<()> {
|
||||
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::Spawn)?;
|
||||
let agent_dir = coord.ensure_runtime(&approval.agent)?;
|
||||
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
|
||||
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
|
||||
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
|
||||
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
|
||||
// Transient guard keeps the per-container "Spawning" pill lit while
|
||||
// the worker is doing the actual nixos-container create. Auto-clears
|
||||
// on the function's scope exit (success or panic).
|
||||
let _guard = coord.transient_guard(&approval.agent, TransientKind::Spawning);
|
||||
coord.set_queue_step(queue_entry_id, "lifecycle::spawn");
|
||||
let result = lifecycle::spawn(
|
||||
&approval.agent,
|
||||
&coord.hyperhive_flake,
|
||||
&agent_dir,
|
||||
&proposed_dir,
|
||||
&applied_dir,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
&coord.operator_pronouns,
|
||||
&coord.context_window_tokens,
|
||||
)
|
||||
.await;
|
||||
if result.is_ok() {
|
||||
coord.set_queue_step(queue_entry_id, "forge user");
|
||||
if let Err(e) = crate::forge::ensure_user_for(&approval.agent).await {
|
||||
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_user after spawn failed");
|
||||
}
|
||||
coord.set_queue_step(queue_entry_id, "forge config repo");
|
||||
if let Err(e) = crate::forge::ensure_config_repo(&approval.agent).await {
|
||||
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_config_repo after spawn failed");
|
||||
}
|
||||
coord.set_queue_step(queue_entry_id, "forge push");
|
||||
if let Err(e) = crate::forge::push_config(&approval.agent).await {
|
||||
tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after spawn failed");
|
||||
}
|
||||
coord.set_queue_step(queue_entry_id, "forge meta access");
|
||||
if let Some(core_token) = crate::forge::core_token()
|
||||
&& let Err(e) = crate::forge::meta_read_access(&approval.agent, &core_token).await
|
||||
{
|
||||
tracing::warn!(agent = %approval.agent, error = ?e, "forge: meta_read_access after spawn failed");
|
||||
}
|
||||
if let Err(e) = crate::forge::ensure_meta_remote(&approval.agent).await {
|
||||
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after spawn failed");
|
||||
}
|
||||
}
|
||||
let final_result = finish_approval(coord, &approval, result, None, false);
|
||||
coord.rescan_containers_and_emit().await;
|
||||
crate::dashboard::emit_tombstones_snapshot(coord).await;
|
||||
final_result
|
||||
}
|
||||
|
||||
/// Re-fetch an approval row from sqlite for a queue-worker dispatch.
|
||||
/// Bails if the row is gone (deny race), if its kind doesn't match,
|
||||
/// or if the lookup itself fails. The kind check is defensive — the
|
||||
/// queue's `dispatch` already routes by `QueueKind`, but the approval
|
||||
/// kind is the authoritative source of truth and a mismatch points
|
||||
/// at a deeper bug we'd want to surface.
|
||||
fn fetch_approval_for_worker(
|
||||
coord: &Coordinator,
|
||||
approval_id: i64,
|
||||
expected_kind: ApprovalKind,
|
||||
) -> Result<hive_sh4re::Approval> {
|
||||
let approval = coord
|
||||
.approvals
|
||||
.get(approval_id)
|
||||
.map_err(|e| anyhow::anyhow!("read approval {approval_id}: {e:#}"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("approval {approval_id} no longer exists"))?;
|
||||
if approval.kind != expected_kind {
|
||||
bail!(
|
||||
"approval {approval_id} kind mismatch: queue expected {expected_kind:?}, row is {actual:?}",
|
||||
actual = approval.kind
|
||||
);
|
||||
}
|
||||
Ok(approval)
|
||||
}
|
||||
|
||||
/// Forge bookkeeping run once after the very first container spawn:
|
||||
/// create the per-agent forge user, mirror the applied repo, and grant
|
||||
/// read access to core/meta. Also rescans containers so the dashboard
|
||||
/// reflects the post-spawn state.
|
||||
async fn forge_after_first_spawn(coord: &Arc<Coordinator>, agent: &str) {
|
||||
if let Err(e) = crate::forge::ensure_user_for(agent).await {
|
||||
tracing::warn!(%agent, error = ?e, "forge: ensure_user after first spawn failed");
|
||||
}
|
||||
if let Err(e) = crate::forge::ensure_config_repo(agent).await {
|
||||
tracing::warn!(%agent, error = ?e, "forge: ensure_config_repo after first spawn failed");
|
||||
}
|
||||
if let Some(core_token) = crate::forge::core_token()
|
||||
&& let Err(e) = crate::forge::meta_read_access(agent, &core_token).await {
|
||||
tracing::warn!(%agent, error = ?e, "forge: meta_read_access after first spawn failed");
|
||||
}
|
||||
if let Err(e) = crate::forge::ensure_meta_remote(agent).await {
|
||||
tracing::warn!(%agent, error = ?e, "forge: ensure_meta_remote after first spawn failed");
|
||||
}
|
||||
coord.rescan_containers_and_emit().await;
|
||||
crate::dashboard::emit_tombstones_snapshot(coord).await;
|
||||
}
|
||||
|
||||
/// Inline (non-queued) handler for `ApprovalKind::InitConfig`. Just
|
||||
/// seeds the proposed git repo + the per-agent dirs — sub-second
|
||||
/// work that doesn't justify a queue card.
|
||||
async fn run_approval_init_config(
|
||||
coord: &Coordinator,
|
||||
approval: hive_sh4re::Approval,
|
||||
proposed_dir: std::path::PathBuf,
|
||||
claude_dir: std::path::PathBuf,
|
||||
notes_dir: std::path::PathBuf,
|
||||
) -> Result<()> {
|
||||
let result: Result<()> = async {
|
||||
lifecycle::setup_proposed(&proposed_dir, &approval.agent).await?;
|
||||
lifecycle::ensure_claude_dir(&claude_dir)?;
|
||||
lifecycle::ensure_state_dir(¬es_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if result.is_ok()
|
||||
&& let Err(e) = crate::forge::ensure_meta_remote(&approval.agent).await
|
||||
{
|
||||
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed");
|
||||
}
|
||||
finish_approval(coord, &approval, result, None, false)
|
||||
}
|
||||
|
||||
fn finish_approval(
|
||||
coord: &Coordinator,
|
||||
approval: &hive_sh4re::Approval,
|
||||
result: Result<()>,
|
||||
terminal_tag: Option<String>,
|
||||
is_first_spawn: bool,
|
||||
) -> Result<()> {
|
||||
let (status, note, ok) = match &result {
|
||||
Ok(()) => (ApprovalStatus::Approved, None, true),
|
||||
Err(e) => {
|
||||
let note = format!("{e:#}");
|
||||
let _ = coord.approvals.mark_failed(approval.id, ¬e);
|
||||
(ApprovalStatus::Failed, Some(note), false)
|
||||
}
|
||||
};
|
||||
coord.notify_manager(&HelperEvent::ApprovalResolved {
|
||||
id: approval.id,
|
||||
agent: approval.agent.clone(),
|
||||
commit_ref: approval.commit_ref.clone(),
|
||||
status,
|
||||
note: note.clone(),
|
||||
sha: approval.fetched_sha.clone(),
|
||||
tag: terminal_tag.clone(),
|
||||
});
|
||||
// Phase 5b: also fire on the dashboard event channel so the
|
||||
// browser moves the row out of pending into history without a
|
||||
// snapshot refetch. `approved` rows that succeed get the
|
||||
// approval's logged resolved_at indirectly via `now_unix()`;
|
||||
// failures already wrote it via mark_failed above.
|
||||
let approval_kind = match approval.kind {
|
||||
ApprovalKind::Spawn => "spawn",
|
||||
ApprovalKind::ApplyCommit => "apply_commit",
|
||||
ApprovalKind::InitConfig => "init_config",
|
||||
ApprovalKind::UpdateMetaInputs => "update_meta_inputs",
|
||||
ApprovalKind::SchedulePrompt => "schedule_prompt",
|
||||
};
|
||||
let sha_short = approval
|
||||
.fetched_sha
|
||||
.as_deref()
|
||||
.map(|s| s[..s.len().min(12)].to_owned());
|
||||
let status_str = if ok { "approved" } else { "failed" };
|
||||
coord.emit_approval_resolved(
|
||||
approval.id,
|
||||
&approval.agent,
|
||||
approval_kind,
|
||||
sha_short,
|
||||
status_str,
|
||||
note.clone(),
|
||||
approval.description.clone(),
|
||||
);
|
||||
// For spawn/rebuild/init_config approvals, also surface the underlying
|
||||
// action so the manager knows whether the lifecycle step succeeded.
|
||||
// The ApprovalResolved event already carries the same `ok` signal but
|
||||
// separating it lets the manager react to the lifecycle change
|
||||
// without having to special-case approvals.
|
||||
match approval.kind {
|
||||
ApprovalKind::InitConfig => {
|
||||
if ok {
|
||||
coord.notify_manager(&HelperEvent::ConfigReady {
|
||||
agent: approval.agent.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
ApprovalKind::Spawn => coord.notify_manager(&HelperEvent::Spawned {
|
||||
agent: approval.agent.clone(),
|
||||
ok,
|
||||
note,
|
||||
sha: approval.fetched_sha.clone(),
|
||||
}),
|
||||
ApprovalKind::ApplyCommit if is_first_spawn => {
|
||||
coord.notify_manager(&HelperEvent::Spawned {
|
||||
agent: approval.agent.clone(),
|
||||
ok,
|
||||
note,
|
||||
sha: approval.fetched_sha.clone(),
|
||||
});
|
||||
}
|
||||
ApprovalKind::ApplyCommit => coord.notify_manager(&HelperEvent::Rebuilt {
|
||||
agent: approval.agent.clone(),
|
||||
ok,
|
||||
note,
|
||||
sha: approval.fetched_sha.clone(),
|
||||
tag: terminal_tag,
|
||||
}),
|
||||
// UpdateMetaInputs / SchedulePrompt: ApprovalResolved already
|
||||
// carries the result. No separate lifecycle event needed.
|
||||
ApprovalKind::UpdateMetaInputs | ApprovalKind::SchedulePrompt => {}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Tag-driven `ApplyCommit` handler. Walks the approval through the tag
|
||||
/// state machine documented in `docs/approvals.md`: stamp
|
||||
/// `approved/<id>` and `building/<id>` first so the audit trail
|
||||
/// captures intent, then drop the candidate tree into the working dir
|
||||
/// without moving HEAD, run the rebuild, and either fast-forward
|
||||
/// `applied/main` to the proposal commit on success
|
||||
/// (`deployed/<id>`) or annotate `failed/<id>` with the build error
|
||||
/// and reset the working tree back to the last known-good main. main
|
||||
/// never advances on a failed build, so a crash-and-recover doesn't
|
||||
/// leave the agent pointing at a tree it can't evaluate.
|
||||
#[allow(clippy::too_many_lines)] // sequential build/tag/notify pipeline; splitting would obscure the flow
|
||||
async fn run_apply_commit(
|
||||
coord: &Arc<Coordinator>,
|
||||
approval: &hive_sh4re::Approval,
|
||||
agent_dir: &std::path::Path,
|
||||
applied_dir: &std::path::Path,
|
||||
claude_dir: &std::path::Path,
|
||||
notes_dir: &std::path::Path,
|
||||
queue_entry_id: Option<u64>,
|
||||
) -> (Result<()>, Option<String>, bool) {
|
||||
let id = approval.id;
|
||||
let proposal_ref = format!("refs/tags/proposal/{id}");
|
||||
|
||||
// Detect first spawn before we touch anything so we can branch on it
|
||||
// throughout this function.
|
||||
let is_first_spawn = !lifecycle::container_exists(&approval.agent).await;
|
||||
|
||||
// Defensive: submit-time should have planted proposal/<id>, but if
|
||||
// the row was migrated from an older schema or the tag got pruned
|
||||
// we fail early with a clear note rather than building a stale
|
||||
// tree.
|
||||
if let Err(e) = lifecycle::git_rev_parse(applied_dir, &proposal_ref).await {
|
||||
return (
|
||||
Err(anyhow::anyhow!(
|
||||
"missing proposal tag {proposal_ref}: {e:#}"
|
||||
)),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
|
||||
// Capture the currently-deployed sha so we can roll applied/main
|
||||
// (and the meta lock indirectly) back if the build fails.
|
||||
let prev_main_sha = match lifecycle::git_rev_parse(applied_dir, "refs/heads/main").await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return (
|
||||
Err(anyhow::anyhow!("read applied/main: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
coord.set_queue_step(queue_entry_id, "plant tags");
|
||||
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("approved/{id}"), &proposal_ref).await
|
||||
{
|
||||
return (
|
||||
Err(anyhow::anyhow!("plant approved/{id}: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("building/{id}"), &proposal_ref).await
|
||||
{
|
||||
return (
|
||||
Err(anyhow::anyhow!("plant building/{id}: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
|
||||
coord.set_queue_step(queue_entry_id, "fast-forward applied/main");
|
||||
// Fast-forward applied/main to proposal/<id> + sync the working
|
||||
// tree. Meta input pins `?ref=main`, so this is what makes nix
|
||||
// re-lock to the proposal commit on the prepare_deploy step
|
||||
// below. On build failure we roll main back to prev_main_sha so
|
||||
// a crash leaves the agent on its last-good tree.
|
||||
if let Err(e) = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &proposal_ref).await {
|
||||
return (
|
||||
Err(anyhow::anyhow!("ff main to {proposal_ref}: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
if let Err(e) = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await {
|
||||
// main is ahead; working tree didn't sync. Roll main back to
|
||||
// keep the two consistent before bailing.
|
||||
let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await;
|
||||
return (
|
||||
Err(anyhow::anyhow!("read-tree to main: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
|
||||
// First spawn: sync_agents must add this agent to the meta flake
|
||||
// before prepare_deploy can update its input lock (which won't
|
||||
// exist yet if this is the agent's first deploy).
|
||||
if is_first_spawn {
|
||||
coord.set_queue_step(queue_entry_id, "meta sync_agents (first spawn)");
|
||||
let agents = match lifecycle::agents_for_meta_listing_with(&approval.agent).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
let _ =
|
||||
lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha)
|
||||
.await;
|
||||
let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await;
|
||||
return (
|
||||
Err(anyhow::anyhow!("agents_for_meta_listing_with: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
};
|
||||
if let Err(e) = crate::meta::sync_agents(
|
||||
&coord.hyperhive_flake,
|
||||
coord.dashboard_port,
|
||||
&coord.operator_pronouns,
|
||||
&coord.context_window_tokens,
|
||||
&agents,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ =
|
||||
lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await;
|
||||
let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await;
|
||||
return (
|
||||
Err(anyhow::anyhow!("meta sync_agents for first spawn: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
coord.set_queue_step(queue_entry_id, "meta prepare_deploy");
|
||||
// Phase 1 of the meta two-phase deploy: relock without committing.
|
||||
if let Err(e) = crate::meta::prepare_deploy(&approval.agent).await {
|
||||
let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await;
|
||||
let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await;
|
||||
return (
|
||||
Err(anyhow::anyhow!("meta prepare_deploy: {e:#}")),
|
||||
None,
|
||||
is_first_spawn,
|
||||
);
|
||||
}
|
||||
|
||||
coord.set_queue_step(queue_entry_id, "nixos-container update");
|
||||
// Container-level rebuild (or first-time create) against meta#<name>.
|
||||
let build_result = lifecycle::rebuild_no_meta(
|
||||
&approval.agent,
|
||||
agent_dir,
|
||||
applied_dir,
|
||||
claude_dir,
|
||||
notes_dir,
|
||||
)
|
||||
.await;
|
||||
|
||||
match build_result {
|
||||
Ok(()) => {
|
||||
coord.set_queue_step(queue_entry_id, "finalize deploy");
|
||||
let tag = format!("deployed/{id}");
|
||||
if let Err(e) = lifecycle::git_tag(applied_dir, &tag, &proposal_ref).await {
|
||||
tracing::warn!(agent = %approval.agent, %id, error = ?e, "plant deployed tag failed");
|
||||
}
|
||||
if let Err(e) = crate::meta::finalize_deploy(
|
||||
&approval.agent,
|
||||
approval.fetched_sha.as_deref().unwrap_or(&proposal_ref),
|
||||
&tag,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// The build itself succeeded — meta lock landed but
|
||||
// couldn't be committed. Surface as a soft warn so the
|
||||
// operator can git-commit by hand if they care.
|
||||
tracing::warn!(agent = %approval.agent, %id, error = ?e, "meta finalize_deploy failed");
|
||||
}
|
||||
// Wake the agent on its next turn so claude sees the
|
||||
// config change took effect. Same hint pattern as
|
||||
// auto_update::rebuild_agent — manager approved a
|
||||
// proposal, agent picks up where it left off with the
|
||||
// new env / packages.
|
||||
coord.kick_agent(&approval.agent, "config update applied");
|
||||
(Ok(()), Some(tag), is_first_spawn)
|
||||
}
|
||||
Err(e) => {
|
||||
let tag = format!("failed/{id}");
|
||||
let body = format!("{e:#}");
|
||||
if let Err(te) =
|
||||
lifecycle::git_tag_annotated(applied_dir, &tag, &proposal_ref, &body).await
|
||||
{
|
||||
tracing::warn!(agent = %approval.agent, %id, error = ?te, "annotate failed tag failed");
|
||||
}
|
||||
// Roll main back to last known-good so the on-disk state
|
||||
// matches what nixos-container last successfully built.
|
||||
if let Err(re) =
|
||||
lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await
|
||||
{
|
||||
tracing::warn!(agent = %approval.agent, %id, error = ?re, "main rollback failed");
|
||||
}
|
||||
if let Err(re) = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await {
|
||||
tracing::warn!(agent = %approval.agent, %id, error = ?re, "rollback read-tree failed");
|
||||
}
|
||||
// Drop the staged meta lock change so the deploy log
|
||||
// only ever shows successes.
|
||||
if let Err(ae) = crate::meta::abort_deploy().await {
|
||||
tracing::warn!(agent = %approval.agent, %id, error = ?ae, "meta abort_deploy failed");
|
||||
}
|
||||
let _ = coord;
|
||||
(Err(e), Some(tag), is_first_spawn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down a sub-agent container. By default this is non-destructive to
|
||||
/// persistent state: the proposed/applied config repos and the Claude
|
||||
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
|
||||
/// kept, so recreating an agent of the same name reuses prior config + creds
|
||||
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
|
||||
/// is cleared because its contents (the mcp socket) don't survive restarts
|
||||
/// anyway. With `purge=true` the persistent trees are also wiped — config
|
||||
/// history, claude creds, notes — there is no undo.
|
||||
/// Refuses the manager (declarative; would fight with the host's nixos config).
|
||||
pub async fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) -> Result<()> {
|
||||
if name == MANAGER_NAME || name == MANAGER_AGENT {
|
||||
bail!("refusing to destroy the manager ({name})");
|
||||
}
|
||||
tracing::info!(%name, purge, "destroy");
|
||||
// Guard auto-clears on the success path's final scope exit and on
|
||||
// every early-return / cancellation along the way.
|
||||
let guard = coord.transient_guard(name, TransientKind::Destroying);
|
||||
lifecycle::destroy(name).await?;
|
||||
coord.unregister_agent(name);
|
||||
let runtime = Coordinator::agent_dir(name);
|
||||
if runtime.exists() {
|
||||
let _ = std::fs::remove_dir_all(&runtime);
|
||||
}
|
||||
if purge {
|
||||
for dir in [
|
||||
Coordinator::agent_state_root(name),
|
||||
Coordinator::agent_applied_dir(name),
|
||||
] {
|
||||
if dir.exists()
|
||||
&& let Err(e) = std::fs::remove_dir_all(&dir)
|
||||
{
|
||||
tracing::warn!(error = ?e, dir = %dir.display(), "purge: remove failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Meta flake: drop the agent's input + nixosConfiguration so a
|
||||
// future spawn under the same name re-seeds cleanly, and so the
|
||||
// meta lock doesn't reference a vanished applied repo. Log + keep
|
||||
// going on failure — destroy already succeeded at the
|
||||
// nixos-container level, the meta repo is just bookkeeping.
|
||||
if let Err(e) = sync_meta_after_lifecycle(coord).await {
|
||||
tracing::warn!(error = ?e, %name, "meta sync after destroy failed");
|
||||
}
|
||||
let _ = coord.approvals.fail_pending_for_agent(
|
||||
name,
|
||||
if purge {
|
||||
"agent purged"
|
||||
} else {
|
||||
"agent destroyed"
|
||||
},
|
||||
);
|
||||
drop(guard);
|
||||
coord.notify_manager(&HelperEvent::Destroyed {
|
||||
agent: name.to_owned(),
|
||||
});
|
||||
// Container row disappeared — rescan so the dashboard fires
|
||||
// `ContainerRemoved` for the gone row, then emit the
|
||||
// tombstones snapshot (gained one on destroy, lost one on
|
||||
// purge — recompute either way).
|
||||
coord.rescan_containers_and_emit().await;
|
||||
crate::dashboard::emit_tombstones_snapshot(coord).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rerender the meta flake from whatever containers still exist on
|
||||
/// disk. Called after lifecycle ops that change the agent set (today:
|
||||
/// destroy). Idempotent — a no-op when nothing changed.
|
||||
async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
|
||||
let agents = lifecycle::agents_for_meta_listing().await?;
|
||||
crate::meta::sync_agents(
|
||||
&coord.hyperhive_flake,
|
||||
coord.dashboard_port,
|
||||
&coord.operator_pronouns,
|
||||
&coord.context_window_tokens,
|
||||
&agents,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
|
||||
let approval = coord.approvals.get(id)?;
|
||||
coord.approvals.mark_denied(id, note)?;
|
||||
tracing::info!(%id, note, "approval denied");
|
||||
let mut tag = None;
|
||||
if let Some(a) = approval {
|
||||
let sha = a.fetched_sha.clone();
|
||||
// ApplyCommit approvals leave a `denied/<id>` tag on the
|
||||
// proposal commit so rejected configs are first-class git
|
||||
// objects — `git show denied/<id>` in the manager's applied
|
||||
// mount yields both the tree the operator rejected and (in
|
||||
// the annotated body) the reason. Spawn approvals have no
|
||||
// commit to tag, so they fall through unannotated.
|
||||
if matches!(a.kind, ApprovalKind::ApplyCommit) {
|
||||
let applied_dir = Coordinator::agent_applied_dir(&a.agent);
|
||||
let proposal_ref = format!("refs/tags/proposal/{id}");
|
||||
if lifecycle::git_rev_parse(&applied_dir, &proposal_ref)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let tag_name = format!("denied/{id}");
|
||||
let body = note.unwrap_or("").to_owned();
|
||||
if let Err(e) =
|
||||
lifecycle::git_tag_annotated(&applied_dir, &tag_name, &proposal_ref, &body)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%id, error = ?e, "plant denied tag failed");
|
||||
} else {
|
||||
tag = Some(tag_name);
|
||||
}
|
||||
}
|
||||
// Mirror the denied/<id> tag to the forge.
|
||||
if let Err(e) = crate::forge::push_config(&a.agent).await {
|
||||
tracing::warn!(%id, agent = %a.agent, error = ?e, "forge: push_config after deny failed");
|
||||
}
|
||||
}
|
||||
let approval_kind = match a.kind {
|
||||
ApprovalKind::Spawn => "spawn",
|
||||
ApprovalKind::ApplyCommit => "apply_commit",
|
||||
ApprovalKind::InitConfig => "init_config",
|
||||
ApprovalKind::UpdateMetaInputs => "update_meta_inputs",
|
||||
ApprovalKind::SchedulePrompt => "schedule_prompt",
|
||||
};
|
||||
let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned());
|
||||
let description = a.description.clone();
|
||||
let agent_owned = a.agent.clone();
|
||||
coord.notify_manager(&HelperEvent::ApprovalResolved {
|
||||
id: a.id,
|
||||
agent: a.agent,
|
||||
commit_ref: a.commit_ref,
|
||||
status: ApprovalStatus::Denied,
|
||||
note: note.map(String::from),
|
||||
sha,
|
||||
tag,
|
||||
});
|
||||
coord.emit_approval_resolved(
|
||||
id,
|
||||
&agent_owned,
|
||||
approval_kind,
|
||||
sha_short,
|
||||
"denied",
|
||||
note.map(String::from),
|
||||
description,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
507
hive-c0re/src/agent_server.rs
Normal file
507
hive-c0re/src/agent_server.rs
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
//! Per-agent socket listener. Each socket file's existence on disk
|
||||
//! authenticates the caller: connecting to `<.../agents/foo/mcp.sock>` means
|
||||
//! you are `foo`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use hive_sh4re::{AgentRequest, AgentResponse, Message};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
pub struct AgentSocket {
|
||||
pub path: PathBuf,
|
||||
pub handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub fn start(agent: &str, socket_path: &Path, coord: Arc<Coordinator>) -> Result<AgentSocket> {
|
||||
let agent = agent.to_owned();
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create agent socket dir {}", parent.display()))?;
|
||||
}
|
||||
if socket_path.exists() {
|
||||
std::fs::remove_file(socket_path).context("remove stale agent socket")?;
|
||||
}
|
||||
let listener = UnixListener::bind(socket_path)
|
||||
.with_context(|| format!("bind agent socket {}", socket_path.display()))?;
|
||||
tracing::info!(%agent, socket = %socket_path.display(), "agent socket listening");
|
||||
|
||||
let path = socket_path.to_path_buf();
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
let agent = agent.clone();
|
||||
let coord = coord.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = serve(stream, agent, coord).await {
|
||||
tracing::warn!(error = ?e, "agent connection failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "agent listener accept failed; exiting");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(AgentSocket { path, handle })
|
||||
}
|
||||
|
||||
async fn serve(stream: UnixStream, agent: String, coord: Arc<Coordinator>) -> Result<()> {
|
||||
let (read, mut write) = stream.into_split();
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let resp = match serde_json::from_str::<AgentRequest>(line.trim()) {
|
||||
Ok(req) => dispatch(&req, &agent, &coord).await,
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("parse error: {e}"),
|
||||
},
|
||||
};
|
||||
let mut payload = serde_json::to_string(&resp)?;
|
||||
payload.push('\n');
|
||||
write.write_all(payload.as_bytes()).await?;
|
||||
write.flush().await?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Max long-poll window the caller can ask for; values above the
|
||||
/// cap are clamped. 180s keeps us under typical TCP/proxy idle
|
||||
/// limits while still letting agents park their turn until a
|
||||
/// message arrives. Omitting `wait_seconds` (or passing `0`) means
|
||||
/// "peek, don't wait" — claude can call recv whenever it wants a
|
||||
/// cheap "is there anything pending?" check without blocking the
|
||||
/// turn for 30 seconds. To actually park, the caller passes a
|
||||
/// positive `wait_seconds`.
|
||||
const RECV_LONG_POLL_MAX: std::time::Duration = std::time::Duration::from_secs(180);
|
||||
|
||||
/// Server-side hard cap on `Recv.max`. Bounds the size of a single
|
||||
/// round-trip so a confused caller can't drain the entire inbox in
|
||||
/// one go and blow past wire-buffer sizes; everything above the cap
|
||||
/// silently clamps. 32 is comfortably above the burst sizes we've
|
||||
/// seen in practice (post-rebuild rescue, multi-agent reply storms)
|
||||
/// and well under the per-message `MESSAGE_MAX_BYTES` * N envelope
|
||||
/// budget.
|
||||
const RECV_BATCH_MAX: u32 = 32;
|
||||
|
||||
fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
|
||||
match wait_seconds {
|
||||
Some(s) => std::time::Duration::from_secs(s).min(RECV_LONG_POLL_MAX),
|
||||
None => std::time::Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) -> AgentResponse {
|
||||
let broker = &coord.broker;
|
||||
match req {
|
||||
AgentRequest::Send { to, body, in_reply_to } => {
|
||||
handle_send(coord, agent, to, body, *in_reply_to)
|
||||
}
|
||||
AgentRequest::Recv { wait_seconds, max } => {
|
||||
let cap = max.unwrap_or(1).min(RECV_BATCH_MAX) as usize;
|
||||
match broker
|
||||
.recv_blocking_batch(agent, recv_timeout(*wait_seconds), cap)
|
||||
.await
|
||||
{
|
||||
Ok(deliveries) => AgentResponse::Messages {
|
||||
messages: deliveries
|
||||
.into_iter()
|
||||
.map(|d| hive_sh4re::DeliveredMessage {
|
||||
from: d.message.from,
|
||||
body: d.message.body,
|
||||
id: d.id,
|
||||
redelivered: d.redelivered,
|
||||
in_reply_to: d.message.in_reply_to,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
AgentRequest::Status => match broker.count_pending(agent) {
|
||||
Ok(unread) => AgentResponse::Status { unread },
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
},
|
||||
AgentRequest::OperatorMsg { body } => match broker.send(&Message {
|
||||
from: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
||||
to: agent.to_owned(),
|
||||
body: body.clone(),
|
||||
in_reply_to: None,
|
||||
}) {
|
||||
Ok(()) => AgentResponse::Ok,
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
},
|
||||
AgentRequest::Wake { from, body } => match broker.send(&Message {
|
||||
from: from.clone(),
|
||||
to: agent.to_owned(),
|
||||
body: body.clone(),
|
||||
in_reply_to: None,
|
||||
}) {
|
||||
Ok(()) => AgentResponse::Ok,
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
},
|
||||
AgentRequest::Recent { limit } => match broker.recent_for(agent, *limit) {
|
||||
Ok(rows) => AgentResponse::Recent { rows },
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
},
|
||||
AgentRequest::Ask {
|
||||
question,
|
||||
options,
|
||||
multi,
|
||||
ttl_seconds,
|
||||
to,
|
||||
} => crate::questions::handle_ask(
|
||||
coord,
|
||||
agent,
|
||||
question,
|
||||
options,
|
||||
*multi,
|
||||
*ttl_seconds,
|
||||
to.as_deref(),
|
||||
)
|
||||
.map_or_else(
|
||||
|message| AgentResponse::Err { message },
|
||||
|id| AgentResponse::QuestionQueued { id },
|
||||
),
|
||||
AgentRequest::Answer { id, answer } => crate::questions::handle_answer(
|
||||
coord, agent, *id, answer,
|
||||
)
|
||||
.map_or_else(
|
||||
|message| AgentResponse::Err { message },
|
||||
|()| AgentResponse::Ok,
|
||||
),
|
||||
AgentRequest::Remind {
|
||||
message,
|
||||
timing,
|
||||
file_path,
|
||||
} => handle_remind(coord, agent, message, timing, file_path.as_deref()),
|
||||
AgentRequest::GetLooseEnds => match crate::loose_ends::for_agent(coord, agent) {
|
||||
Ok(loose_ends) => AgentResponse::LooseEnds { loose_ends },
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
},
|
||||
AgentRequest::CountPendingReminders => {
|
||||
match coord.broker.count_pending_reminders_for(agent) {
|
||||
Ok(count) => AgentResponse::PendingRemindersCount { count },
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
AgentRequest::ReminderRollup { since_secs } => {
|
||||
match coord.broker.reminder_rollup_for(agent, *since_secs) {
|
||||
Ok(stats) => AgentResponse::ReminderRollup(stats),
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
AgentRequest::SetStatus { text } => {
|
||||
let path = crate::coordinator::Coordinator::agent_notes_dir(agent)
|
||||
.join("hyperhive-status");
|
||||
let result = if text.trim().is_empty() {
|
||||
// Empty = clear: remove the file (ignore missing).
|
||||
std::fs::remove_file(&path)
|
||||
.or_else(|e| if e.kind() == std::io::ErrorKind::NotFound {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(e)
|
||||
})
|
||||
} else {
|
||||
std::fs::write(&path, format!("{}\n", text.trim()))
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
// Kick a container rescan so the dashboard updates live.
|
||||
let coord2 = Arc::clone(coord);
|
||||
tokio::spawn(async move { coord2.rescan_containers_and_emit().await });
|
||||
AgentResponse::Ok
|
||||
}
|
||||
Err(e) => AgentResponse::Err { message: format!("set_status write failed: {e}") },
|
||||
}
|
||||
}
|
||||
AgentRequest::GetAgentMeta { name } => {
|
||||
let target = name.as_deref().unwrap_or(agent);
|
||||
// #432: gate status on the target's running state so a
|
||||
// stopped container's stale on-disk status doesn't leak
|
||||
// through. Also surface `running` itself so callers can
|
||||
// tell (e.g. "iris is down" vs "iris has no status set").
|
||||
let (status_text, status_set_at, running) =
|
||||
crate::container_view::read_agent_status_live(target).await;
|
||||
let role = if target == hive_sh4re::MANAGER_AGENT {
|
||||
"manager"
|
||||
} else {
|
||||
"agent"
|
||||
}
|
||||
.to_owned();
|
||||
AgentResponse::AgentMeta {
|
||||
name: target.to_owned(),
|
||||
role,
|
||||
running,
|
||||
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake),
|
||||
status_text,
|
||||
status_set_at,
|
||||
}
|
||||
}
|
||||
AgentRequest::CancelLooseEnd { kind, id } => crate::questions::handle_cancel_loose_end(
|
||||
coord, agent, *kind, *id,
|
||||
)
|
||||
.map_or_else(
|
||||
|message| AgentResponse::Err { message },
|
||||
|()| AgentResponse::Ok,
|
||||
),
|
||||
AgentRequest::AckTurn => match broker.ack_turn(agent) {
|
||||
Ok(_n) => AgentResponse::Ok,
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
},
|
||||
AgentRequest::RequeueInflight => match broker.requeue_inflight(agent) {
|
||||
Ok(n) => {
|
||||
if n > 0 {
|
||||
tracing::info!(%agent, requeued = %n, "requeued in-flight messages");
|
||||
}
|
||||
AgentResponse::Ok
|
||||
}
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Common Send handler shared between dispatch arms. Applies the
|
||||
/// 4 KiB body cap, then routes broadcast (`to == "*"`) vs unicast
|
||||
/// through their respective broker calls. Pulled out of `dispatch`
|
||||
/// to keep that function under the clippy too-many-lines limit; the
|
||||
/// behaviour is identical to inlining.
|
||||
fn handle_send(
|
||||
coord: &Arc<Coordinator>,
|
||||
agent: &str,
|
||||
to: &str,
|
||||
body: &str,
|
||||
in_reply_to: Option<i64>,
|
||||
) -> AgentResponse {
|
||||
if let Err(message) = crate::limits::check_size("send", body) {
|
||||
return AgentResponse::Err { message };
|
||||
}
|
||||
if to == "*" {
|
||||
let errors = coord.broadcast_send(agent, body);
|
||||
return if errors.is_empty() {
|
||||
AgentResponse::Ok
|
||||
} else {
|
||||
AgentResponse::Err {
|
||||
message: format!("broadcast failed for agents: {}", errors.join(", ")),
|
||||
}
|
||||
};
|
||||
}
|
||||
match coord.broker.send(&Message {
|
||||
from: agent.to_owned(),
|
||||
to: to.to_owned(),
|
||||
body: body.to_owned(),
|
||||
in_reply_to,
|
||||
}) {
|
||||
Ok(()) => AgentResponse::Ok,
|
||||
Err(e) => AgentResponse::Err {
|
||||
message: format!("{e:#}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_remind(
|
||||
coord: &Arc<Coordinator>,
|
||||
agent: &str,
|
||||
message: &str,
|
||||
timing: &hive_sh4re::ReminderTiming,
|
||||
file_path: Option<&str>,
|
||||
) -> AgentResponse {
|
||||
match store_remind(coord, agent, message, timing, file_path) {
|
||||
Ok(()) => AgentResponse::Ok,
|
||||
Err(message) => AgentResponse::Err { message },
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared remind-storage path used by both the agent and the manager
|
||||
/// dispatchers. Validates timing, applies the auto-file overflow
|
||||
/// dance (see [`prepare_remind_storage`]), and writes the reminder
|
||||
/// row. Returns `Ok(())` on success, or a caller-ready error string
|
||||
/// the dispatcher wraps in `*Response::Err`.
|
||||
/// Maximum pending (un-delivered) reminders per agent. Exceeding this
|
||||
/// causes `store_remind` to return an error so the agent knows to back
|
||||
/// off instead of silently dropping. Override via
|
||||
/// `HIVE_REMIND_MAX_PENDING_PER_AGENT`; set to `0` to disable the cap
|
||||
/// (not recommended — a runaway agent can still flood the scheduler).
|
||||
const DEFAULT_REMIND_MAX_PENDING: u64 = 50;
|
||||
|
||||
fn remind_max_pending() -> u64 {
|
||||
std::env::var("HIVE_REMIND_MAX_PENDING_PER_AGENT")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.unwrap_or(DEFAULT_REMIND_MAX_PENDING)
|
||||
}
|
||||
|
||||
pub(crate) fn store_remind(
|
||||
coord: &Arc<Coordinator>,
|
||||
agent: &str,
|
||||
message: &str,
|
||||
timing: &hive_sh4re::ReminderTiming,
|
||||
file_path: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let max = remind_max_pending();
|
||||
if max > 0 {
|
||||
let pending = coord
|
||||
.broker
|
||||
.count_pending_reminders_for(agent)
|
||||
.unwrap_or(0);
|
||||
if pending >= max {
|
||||
return Err(format!(
|
||||
"reminder rejected: agent `{agent}` already has {pending} pending \
|
||||
reminders (cap {max}). Cancel some via `cancel_loose_end` or wait \
|
||||
for them to fire before scheduling more. Override the cap with \
|
||||
`HIVE_REMIND_MAX_PENDING_PER_AGENT`."
|
||||
));
|
||||
}
|
||||
}
|
||||
let due_at = resolve_due_at(timing).map_err(|e| format!("invalid reminder timing: {e:#}"))?;
|
||||
let (stored_message, stored_path) = prepare_remind_storage(agent, message, file_path)?;
|
||||
let id = coord
|
||||
.broker
|
||||
.store_reminder(agent, &stored_message, stored_path.as_deref(), due_at)
|
||||
.map_err(|e| format!("failed to store reminder: {e:#}"))?;
|
||||
tracing::info!(%id, %agent, %due_at, "reminder scheduled");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decide what we actually store in the reminders row, applying the
|
||||
/// same byte cap as the rest of the wire protocol
|
||||
/// ([`crate::limits::MESSAGE_MAX_BYTES`]). Three outcomes:
|
||||
///
|
||||
/// 1. Body within the cap → stored verbatim, with whatever `file_path`
|
||||
/// the caller passed (None or Some). The scheduler honours
|
||||
/// `file_path` at delivery time as before.
|
||||
/// 2. Body over the cap, no caller `file_path` → auto-generate a path
|
||||
/// under `/agents/<agent>/state/reminders/auto-<ts>.md`, write the
|
||||
/// body to disk now, store a short pointer hint as the message and
|
||||
/// clear `file_path` (so the scheduler doesn't re-write at
|
||||
/// delivery and overwrite the body with the hint).
|
||||
/// 3. Body over the cap, caller provided `file_path` → honour the
|
||||
/// caller's path: write the body to it now, store the same hint
|
||||
/// and clear `file_path` for the same reason as (2).
|
||||
///
|
||||
/// Returns `(stored_message, stored_file_path)` on success, or a
|
||||
/// caller-ready error string on auto-save failure (which is the only
|
||||
/// way a Remind request can be refused for size — the agent never has
|
||||
/// to think about the cap).
|
||||
fn prepare_remind_storage(
|
||||
agent: &str,
|
||||
message: &str,
|
||||
file_path: Option<&str>,
|
||||
) -> Result<(String, Option<String>), String> {
|
||||
if message.len() <= crate::limits::MESSAGE_MAX_BYTES {
|
||||
return Ok((message.to_owned(), file_path.map(str::to_owned)));
|
||||
}
|
||||
let req_path = match file_path {
|
||||
Some(p) => p.to_owned(),
|
||||
None => auto_reminder_path(agent),
|
||||
};
|
||||
let host_path = crate::reminder_scheduler::resolve_host_path(agent, &req_path)
|
||||
.map_err(|reason| format!("auto-save path `{req_path}` rejected: {reason}"))?;
|
||||
crate::reminder_scheduler::write_payload(agent, &host_path, message)
|
||||
.map_err(|reason| format!("auto-save of large reminder body to `{req_path}` failed: {reason}"))?;
|
||||
let hint = format!(
|
||||
"[reminder body of {} bytes auto-saved to `{req_path}`; read with your filesystem tools]",
|
||||
message.len()
|
||||
);
|
||||
Ok((hint, None))
|
||||
}
|
||||
|
||||
/// Generate a per-agent path for an auto-saved reminder body. Uses
|
||||
/// `unix_nanos` plus the agent name to keep collisions infinitesimal
|
||||
/// across the agent's own state subtree (we're not stamping a hostname
|
||||
/// since hive-c0re is single-host).
|
||||
fn auto_reminder_path(agent: &str) -> String {
|
||||
let ts_ns = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("/agents/{agent}/state/reminders/auto-{ts_ns}.md")
|
||||
}
|
||||
|
||||
/// Resolve the `due_at` unix timestamp for a Remind request. Returns
|
||||
/// distinct error messages for each failure mode (overflow on
|
||||
/// `InSeconds`, pre-epoch clock, `i64` cast wrap) so the caller can tell
|
||||
/// what went wrong without inspecting the chain.
|
||||
fn resolve_due_at(timing: &hive_sh4re::ReminderTiming) -> anyhow::Result<i64> {
|
||||
use hive_sh4re::ReminderTiming;
|
||||
match timing {
|
||||
ReminderTiming::InSeconds { seconds } => {
|
||||
let now = std::time::SystemTime::now();
|
||||
let future = now
|
||||
.checked_add(std::time::Duration::from_secs(*seconds))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("InSeconds overflow: {seconds}s exceeds system time range")
|
||||
})?;
|
||||
let duration = future
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| anyhow::anyhow!("system time before UNIX_EPOCH: {e}"))?;
|
||||
i64::try_from(duration.as_secs())
|
||||
.map_err(|e| anyhow::anyhow!("unix timestamp exceeds i64 range: {e}"))
|
||||
}
|
||||
ReminderTiming::At { unix_timestamp } => Ok(*unix_timestamp),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auto_reminder_path_format() {
|
||||
let p = auto_reminder_path("damocles");
|
||||
assert!(p.starts_with("/agents/damocles/state/reminders/auto-"));
|
||||
assert!(
|
||||
std::path::Path::new(&p)
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_remind_storage_passthrough_under_cap() {
|
||||
let (msg, fp) = prepare_remind_storage("foo", "small body", None).unwrap();
|
||||
assert_eq!(msg, "small body");
|
||||
assert_eq!(fp, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_remind_storage_passthrough_with_caller_file_path() {
|
||||
let (msg, fp) =
|
||||
prepare_remind_storage("foo", "small", Some("/agents/foo/state/x.md")).unwrap();
|
||||
assert_eq!(msg, "small");
|
||||
assert_eq!(fp.as_deref(), Some("/agents/foo/state/x.md"));
|
||||
}
|
||||
}
|
||||
519
hive-c0re/src/approvals.rs
Normal file
519
hive-c0re/src/approvals.rs
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
//! Approval queue. Manager submits via `RequestApplyCommit`; the user
|
||||
//! approves/denies via the host admin CLI; on approval the host runs the
|
||||
//! corresponding action (Phase 5a: `lifecycle::rebuild(agent)`).
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use hive_sh4re::{Approval, ApprovalKind, ApprovalStatus};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
const SCHEMA: &str = r"
|
||||
CREATE TABLE IF NOT EXISTS approvals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent TEXT NOT NULL,
|
||||
commit_ref TEXT NOT NULL,
|
||||
requested_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
resolved_at INTEGER,
|
||||
note TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_approvals_pending
|
||||
ON approvals (id) WHERE status = 'pending';
|
||||
";
|
||||
|
||||
/// Add the `description` column to pre-description databases. Manager-supplied
|
||||
/// note shown on the dashboard approval card at submission time (distinct from
|
||||
/// `note` which is set on denial/failure).
|
||||
fn ensure_description_column(conn: &Connection) -> Result<()> {
|
||||
let has: bool = conn
|
||||
.prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'description'")?
|
||||
.exists([])?;
|
||||
if !has {
|
||||
conn.execute_batch("ALTER TABLE approvals ADD COLUMN description TEXT;")
|
||||
.context("add approvals.description column")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add the `kind` column to pre-Phase-8 databases. ALTER TABLE ADD COLUMN is
|
||||
/// idempotent here only via a column-existence check (sqlite doesn't support
|
||||
/// IF NOT EXISTS on ADD COLUMN). Defaults legacy rows to `apply_commit`,
|
||||
/// which matches their actual semantics.
|
||||
fn ensure_kind_column(conn: &Connection) -> Result<()> {
|
||||
let has_kind: bool = conn
|
||||
.prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'kind'")?
|
||||
.exists([])?;
|
||||
if !has_kind {
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE approvals ADD COLUMN kind TEXT NOT NULL DEFAULT 'apply_commit';",
|
||||
)
|
||||
.context("add approvals.kind column")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Same shape as `ensure_kind_column` but for `fetched_sha` — the
|
||||
/// canonical sha hive-c0re vouched for at `request_apply_commit` time.
|
||||
/// Distinct from `commit_ref` (manager-supplied, may not even resolve
|
||||
/// in proposed by the time we approve).
|
||||
fn ensure_fetched_sha_column(conn: &Connection) -> Result<()> {
|
||||
let has: bool = conn
|
||||
.prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'fetched_sha'")?
|
||||
.exists([])?;
|
||||
if !has {
|
||||
conn.execute_batch("ALTER TABLE approvals ADD COLUMN fetched_sha TEXT;")
|
||||
.context("add approvals.fetched_sha column")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Approvals {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl Approvals {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create approvals db parent {}", parent.display()))?;
|
||||
}
|
||||
let conn = Connection::open(path)
|
||||
.with_context(|| format!("open approvals db {}", path.display()))?;
|
||||
conn.execute_batch(SCHEMA)
|
||||
.context("apply approvals schema")?;
|
||||
ensure_kind_column(&conn).context("migrate approvals.kind")?;
|
||||
ensure_fetched_sha_column(&conn).context("migrate approvals.fetched_sha")?;
|
||||
ensure_description_column(&conn).context("migrate approvals.description")?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn submit_kind(
|
||||
&self,
|
||||
agent: &str,
|
||||
kind: ApprovalKind,
|
||||
commit_ref: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status, description)
|
||||
VALUES (?1, ?2, ?3, ?4, 'pending', ?5)",
|
||||
params![
|
||||
agent,
|
||||
kind_to_str(kind),
|
||||
commit_ref,
|
||||
now_unix(),
|
||||
description
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Record the canonical sha hive-c0re fetched from the proposed repo
|
||||
/// into applied at submission time. Idempotent on identical values.
|
||||
pub fn set_fetched_sha(&self, id: i64, sha: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE approvals SET fetched_sha = ?1 WHERE id = ?2",
|
||||
params![sha, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Last `limit` resolved approvals (approved / denied / failed),
|
||||
/// newest-first. Drives the history tab on the dashboard.
|
||||
pub fn recent_resolved(&self, limit: u64) -> Result<Vec<Approval>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description
|
||||
FROM approvals
|
||||
WHERE status IN ('approved', 'denied', 'failed', 'cancelled')
|
||||
ORDER BY resolved_at DESC, id DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([limit], row_to_approval)?;
|
||||
Ok(collect_lenient(rows))
|
||||
}
|
||||
|
||||
pub fn pending(&self) -> Result<Vec<Approval>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description
|
||||
FROM approvals
|
||||
WHERE status = 'pending'
|
||||
ORDER BY id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], row_to_approval)?;
|
||||
Ok(collect_lenient(rows))
|
||||
}
|
||||
|
||||
pub fn get(&self, id: i64) -> Result<Option<Approval>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description
|
||||
FROM approvals WHERE id = ?1",
|
||||
params![id],
|
||||
row_to_approval,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Mark pending -> approved (or fail if not pending). Returns the (now-updated)
|
||||
/// approval so the caller can run the action and pass the agent name.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn mark_approved(&self, id: i64) -> Result<Approval> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// Row shape: (agent, kind, commit_ref, requested_at, status,
|
||||
// fetched_sha, description).
|
||||
let current: Option<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)> = conn
|
||||
.query_row(
|
||||
"SELECT agent, kind, commit_ref, requested_at, status, fetched_sha, description
|
||||
FROM approvals WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
row.get(6)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let Some((agent, kind, commit_ref, requested_at, status, fetched_sha, description)) =
|
||||
current
|
||||
else {
|
||||
bail!("approval {id} not found");
|
||||
};
|
||||
if status != "pending" {
|
||||
bail!("approval {id} is {status}, not pending");
|
||||
}
|
||||
let resolved_at = now_unix();
|
||||
conn.execute(
|
||||
"UPDATE approvals SET status = 'approved', resolved_at = ?1 WHERE id = ?2",
|
||||
params![resolved_at, id],
|
||||
)?;
|
||||
Ok(Approval {
|
||||
id,
|
||||
agent,
|
||||
kind: kind_from_str(&kind)?,
|
||||
commit_ref,
|
||||
requested_at,
|
||||
status: ApprovalStatus::Approved,
|
||||
resolved_at: Some(resolved_at),
|
||||
note: None,
|
||||
fetched_sha,
|
||||
description,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mark_denied(&self, id: i64, note: Option<&str>) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let affected = conn.execute(
|
||||
"UPDATE approvals SET status = 'denied', resolved_at = ?1, note = ?2
|
||||
WHERE id = ?3 AND status = 'pending'",
|
||||
params![now_unix(), note, id],
|
||||
)?;
|
||||
if affected == 0 {
|
||||
bail!("approval {id} not pending");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_failed(&self, id: i64, note: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE approvals SET status = 'failed', resolved_at = ?1, note = ?2 WHERE id = ?3",
|
||||
params![now_unix(), note, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Withdraw a pending approval (closes #250). Returns the now-updated
|
||||
/// row so the caller can emit `ApprovalResolved` with the right
|
||||
/// kind / agent / sha. Errors if the approval isn't pending — once
|
||||
/// it's approved/denied/failed/cancelled, the resolution is final.
|
||||
pub fn mark_cancelled(&self, id: i64, canceller: &str) -> Result<Approval> {
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
let row: Option<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)> = tx
|
||||
.query_row(
|
||||
"SELECT agent, kind, commit_ref, requested_at, status, fetched_sha, description
|
||||
FROM approvals WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
row.get(6)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let Some((agent, kind, commit_ref, requested_at, status, fetched_sha, description)) = row
|
||||
else {
|
||||
bail!("approval {id} not found");
|
||||
};
|
||||
if status != "pending" {
|
||||
bail!("approval {id} is {status}, not pending");
|
||||
}
|
||||
let resolved_at = now_unix();
|
||||
let note = format!("cancelled by {canceller}");
|
||||
tx.execute(
|
||||
"UPDATE approvals SET status = 'cancelled', resolved_at = ?1, note = ?2 WHERE id = ?3",
|
||||
params![resolved_at, note, id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(Approval {
|
||||
id,
|
||||
agent,
|
||||
kind: kind_from_str(&kind)?,
|
||||
commit_ref,
|
||||
requested_at,
|
||||
status: ApprovalStatus::Cancelled,
|
||||
resolved_at: Some(resolved_at),
|
||||
note: Some(note),
|
||||
fetched_sha,
|
||||
description,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark every pending approval for `agent` as failed (returns rows affected).
|
||||
/// Used by `destroy` to clear the queue of an agent that no longer exists.
|
||||
pub fn fail_pending_for_agent(&self, agent: &str, note: &str) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let n = conn.execute(
|
||||
"UPDATE approvals SET status = 'failed', resolved_at = ?1, note = ?2
|
||||
WHERE agent = ?3 AND status = 'pending'",
|
||||
params![now_unix(), note, agent],
|
||||
)?;
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect approval rows, dropping (and logging) any that fail to
|
||||
/// deserialize. A single malformed / unknown-kind row must never blank
|
||||
/// the whole list: `collect::<Result<Vec>>()` is all-or-nothing, so one
|
||||
/// bad row used to make `pending()` / `recent_resolved()` error out
|
||||
/// wholesale — the dashboard then rendered an empty approvals queue
|
||||
/// (issue #160, an unhandled `init_config` kind poisoning every read).
|
||||
fn collect_lenient(
|
||||
rows: impl Iterator<Item = rusqlite::Result<Approval>>,
|
||||
) -> Vec<Approval> {
|
||||
rows.filter_map(|r| match r {
|
||||
Ok(a) => Some(a),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "skipping unparseable approval row");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
|
||||
// Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description.
|
||||
let kind: String = row.get(2)?;
|
||||
let kind = match kind.as_str() {
|
||||
"apply_commit" => ApprovalKind::ApplyCommit,
|
||||
"spawn" => ApprovalKind::Spawn,
|
||||
"init_config" => ApprovalKind::InitConfig,
|
||||
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
||||
"schedule_prompt" => ApprovalKind::SchedulePrompt,
|
||||
other => {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
2,
|
||||
rusqlite::types::Type::Text,
|
||||
format!("unknown approval kind '{other}'").into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let status: String = row.get(5)?;
|
||||
let status = match status.as_str() {
|
||||
"pending" => ApprovalStatus::Pending,
|
||||
"approved" => ApprovalStatus::Approved,
|
||||
"denied" => ApprovalStatus::Denied,
|
||||
"failed" => ApprovalStatus::Failed,
|
||||
"cancelled" => ApprovalStatus::Cancelled,
|
||||
other => {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
5,
|
||||
rusqlite::types::Type::Text,
|
||||
format!("unknown approval status '{other}'").into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(Approval {
|
||||
id: row.get(0)?,
|
||||
agent: row.get(1)?,
|
||||
kind,
|
||||
commit_ref: row.get(3)?,
|
||||
requested_at: row.get(4)?,
|
||||
status,
|
||||
resolved_at: row.get(6)?,
|
||||
note: row.get(7)?,
|
||||
fetched_sha: row.get(8)?,
|
||||
description: row.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stable kind→str mapping used wherever we emit `ApprovalResolved`
|
||||
/// or persist a kind to sqlite. `pub(crate)` so callers like
|
||||
/// `questions::handle_cancel_loose_end` don't have to duplicate the
|
||||
/// match; bumping a kind here is the single source of truth.
|
||||
pub(crate) fn kind_to_str(kind: ApprovalKind) -> &'static str {
|
||||
match kind {
|
||||
ApprovalKind::ApplyCommit => "apply_commit",
|
||||
ApprovalKind::Spawn => "spawn",
|
||||
ApprovalKind::InitConfig => "init_config",
|
||||
ApprovalKind::UpdateMetaInputs => "update_meta_inputs",
|
||||
ApprovalKind::SchedulePrompt => "schedule_prompt",
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_from_str(s: &str) -> Result<ApprovalKind> {
|
||||
Ok(match s {
|
||||
"apply_commit" => ApprovalKind::ApplyCommit,
|
||||
"spawn" => ApprovalKind::Spawn,
|
||||
"init_config" => ApprovalKind::InitConfig,
|
||||
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
|
||||
"schedule_prompt" => ApprovalKind::SchedulePrompt,
|
||||
other => bail!("unknown approval kind '{other}'"),
|
||||
})
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hive_sh4re::ApprovalKind;
|
||||
|
||||
fn open_temp() -> (tempfile::TempDir, std::path::PathBuf, Approvals) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("approvals.sqlite");
|
||||
let db = Approvals::open(&path).expect("open approvals db");
|
||||
(dir, path, db)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_config_approval_round_trips() {
|
||||
// Regression for #160: an `init_config` row used to fail
|
||||
// deserialization (row_to_approval matched only apply_commit +
|
||||
// spawn), erroring out the whole `pending()` query — every
|
||||
// approval then vanished from the dashboard.
|
||||
let (_dir, _path, db) = open_temp();
|
||||
let id = db
|
||||
.submit_kind("bitburner", ApprovalKind::InitConfig, "", Some("scaffold"))
|
||||
.expect("submit init_config");
|
||||
let pending = db
|
||||
.pending()
|
||||
.expect("pending() must not error on an init_config row");
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, id);
|
||||
assert!(matches!(pending[0].kind, ApprovalKind::InitConfig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_kinds_all_listed() {
|
||||
let (_dir, _path, db) = open_temp();
|
||||
db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None)
|
||||
.unwrap();
|
||||
db.submit_kind("b", ApprovalKind::Spawn, "", None).unwrap();
|
||||
db.submit_kind("c", ApprovalKind::InitConfig, "", None)
|
||||
.unwrap();
|
||||
let pending = db.pending().expect("pending");
|
||||
assert_eq!(pending.len(), 3, "all three kinds must be visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_cancelled_transitions_pending_row() {
|
||||
// #250: manager withdraws a pending approval. Row leaves
|
||||
// pending(), shows up in recent_resolved() with the cancelled
|
||||
// status + a "cancelled by <who>" note.
|
||||
let (_dir, _path, db) = open_temp();
|
||||
let id = db
|
||||
.submit_kind("bitburner", ApprovalKind::ApplyCommit, "cafef00d", Some("test"))
|
||||
.unwrap();
|
||||
let row = db.mark_cancelled(id, "manager").expect("cancel");
|
||||
assert_eq!(row.id, id);
|
||||
assert!(matches!(row.status, ApprovalStatus::Cancelled));
|
||||
assert_eq!(row.note.as_deref(), Some("cancelled by manager"));
|
||||
assert!(row.resolved_at.is_some());
|
||||
assert!(db.pending().unwrap().is_empty(), "row leaves pending");
|
||||
let resolved = db.recent_resolved(10).unwrap();
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert!(matches!(resolved[0].status, ApprovalStatus::Cancelled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_cancelled_refuses_already_resolved_row() {
|
||||
// Once approved/denied/failed/cancelled the resolution is
|
||||
// final — re-cancelling errors instead of silently overwriting.
|
||||
let (_dir, _path, db) = open_temp();
|
||||
let id = db
|
||||
.submit_kind("a", ApprovalKind::Spawn, "deadbeef", None)
|
||||
.unwrap();
|
||||
db.mark_cancelled(id, "manager").expect("first cancel");
|
||||
let err = db
|
||||
.mark_cancelled(id, "manager")
|
||||
.expect_err("second cancel must fail");
|
||||
assert!(err.to_string().contains("not pending"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kind_row_is_skipped_not_fatal() {
|
||||
// A single malformed / future-kind row must not blank the
|
||||
// whole list — collect_lenient skips it instead of failing.
|
||||
let (_dir, path, db) = open_temp();
|
||||
let good = db
|
||||
.submit_kind("good", ApprovalKind::ApplyCommit, "cafe", None)
|
||||
.unwrap();
|
||||
let raw = Connection::open(&path).unwrap();
|
||||
raw.execute(
|
||||
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
|
||||
VALUES ('weird', 'from_the_future', '', 0, 'pending')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let pending = db
|
||||
.pending()
|
||||
.expect("pending() must survive an unparseable row");
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, good);
|
||||
}
|
||||
}
|
||||
241
hive-c0re/src/auto_update.rs
Normal file
241
hive-c0re/src/auto_update.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
//! Startup auto-update: on `hive-c0re serve` boot, rebuild every known
|
||||
//! container unconditionally. `nixos-container update` is a no-op at the
|
||||
//! nix level when nothing changed (same store path), so the cost of always
|
||||
//! running it on startup is low and avoids the complexity of rev-marker
|
||||
//! staleness (issue #179: all agents always needed update when any meta
|
||||
//! commit landed).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
|
||||
|
||||
/// Marker file recording the hyperhive rev a sub-agent's container was last
|
||||
/// built against. Sibling of `applied/<name>/` (rather than inside it) to
|
||||
/// keep it out of the applied repo's git history. Uses a leading dot so a
|
||||
/// glob over `applied/*` doesn't include it.
|
||||
pub fn rev_marker_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(format!("/var/lib/hyperhive/applied/.{name}.hyperhive-rev"))
|
||||
}
|
||||
|
||||
/// Resolve the current rev of `hyperhive_flake`. For a path on disk we
|
||||
/// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/...
|
||||
/// update yields a different string. For anything else we return None.
|
||||
#[must_use]
|
||||
pub fn current_flake_rev(hyperhive_flake: &str) -> Option<String> {
|
||||
let path = Path::new(hyperhive_flake);
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
std::fs::canonicalize(path)
|
||||
.ok()
|
||||
.map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
/// Returns true when the applied repo has commits that have not yet been
|
||||
/// deployed (i.e. the applied HEAD differs from the sha currently locked in
|
||||
/// meta's flake.lock). This is the semantic the dashboard `needs_update` chip
|
||||
/// conveys: "there is a config change ready to apply via rebuild."
|
||||
#[must_use]
|
||||
pub fn agent_config_pending(name: &str, deployed_sha: Option<&str>) -> bool {
|
||||
let applied_head = std::process::Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
&format!("/var/lib/hyperhive/applied/{name}"),
|
||||
"rev-parse",
|
||||
"HEAD",
|
||||
])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_owned());
|
||||
|
||||
match (applied_head.as_deref(), deployed_sha) {
|
||||
(Some(head), Some(sha)) => !head.starts_with(sha) && !sha.starts_with(head),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild one sub-agent and refresh its marker. Used by both the startup
|
||||
/// scanner and the dashboard's manual "update" button so the two paths
|
||||
/// can't diverge.
|
||||
///
|
||||
/// `queue_entry_id` is `Some(id)` when the rebuild was dispatched from
|
||||
/// the rebuild_queue worker (lets the function annotate its phase via
|
||||
/// `coord.set_queue_step`) and `None` when called directly (e.g. the
|
||||
/// manager-migration nudge in `ensure_manager`).
|
||||
pub async fn rebuild_agent(
|
||||
coord: &Arc<Coordinator>,
|
||||
name: &str,
|
||||
current_rev: &str,
|
||||
queue_entry_id: Option<u64>,
|
||||
) -> Result<()> {
|
||||
tracing::info!(%name, rev = %current_rev, "rebuild agent");
|
||||
let agent_dir = coord
|
||||
.ensure_runtime(name)
|
||||
.with_context(|| format!("ensure_runtime {name}"))?;
|
||||
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||
let claude_dir = Coordinator::agent_claude_dir(name);
|
||||
let notes_dir = Coordinator::agent_notes_dir(name);
|
||||
// Suppress crash_watch during the stop+start window inside
|
||||
// lifecycle::rebuild. Dashboard rebuilds already do this via
|
||||
// lifecycle_action; this catches the auto-update scan + any
|
||||
// other direct caller.
|
||||
let guard = coord.transient_guard(name, crate::coordinator::TransientKind::Rebuilding);
|
||||
coord.set_queue_step(queue_entry_id, "nixos-container update");
|
||||
let result = lifecycle::rebuild(
|
||||
name,
|
||||
&coord.hyperhive_flake,
|
||||
&agent_dir,
|
||||
&applied_dir,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
&coord.operator_pronouns,
|
||||
&coord.context_window_tokens,
|
||||
)
|
||||
.await;
|
||||
drop(guard);
|
||||
match &result {
|
||||
Ok(()) => {
|
||||
if let Err(e) = std::fs::write(rev_marker_path(name), current_rev) {
|
||||
tracing::warn!(%name, error = ?e, "write rev marker failed");
|
||||
}
|
||||
coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
|
||||
agent: name.to_owned(),
|
||||
ok: true,
|
||||
note: None,
|
||||
sha: None,
|
||||
tag: None,
|
||||
});
|
||||
coord.set_queue_step(queue_entry_id, "forge sync");
|
||||
// Run the full forge sync on every successful rebuild so
|
||||
// the rebuild path is equivalent to the hive-c0re startup
|
||||
// sweep: token, config-repo mirror, meta read access, and
|
||||
// meta remote are all kept in sync. Recovers missing tokens
|
||||
// (e.g. first-spawn seeding failed transiently) without
|
||||
// requiring a full hive-c0re restart.
|
||||
crate::forge::sync_agent(name, crate::forge::core_token().as_deref()).await;
|
||||
// Wake the agent on its next turn so claude sees a
|
||||
// "you were rebuilt — check /state/ for notes, --continue
|
||||
// session intact" hint. Covers dashboard rebuild, admin
|
||||
// CLI rebuild, auto-update startup scan, and the
|
||||
// dashboard's meta-input update path — all of which
|
||||
// route through rebuild_agent.
|
||||
coord.kick_agent(name, "container rebuilt");
|
||||
// Container state (needs_update, deployed_sha) may have
|
||||
// shifted — rescan so dashboards drop the "needs update"
|
||||
// chip without waiting for the next /api/state poll.
|
||||
coord.rescan_containers_and_emit().await;
|
||||
// Lock bump → meta-inputs panel needs to re-render.
|
||||
crate::dashboard::emit_meta_inputs_snapshot(coord);
|
||||
}
|
||||
Err(e) => {
|
||||
coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
|
||||
agent: name.to_owned(),
|
||||
ok: false,
|
||||
note: Some(format!("{e:#}")),
|
||||
sha: None,
|
||||
tag: None,
|
||||
});
|
||||
coord.rescan_containers_and_emit().await;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Auto-create the manager container on startup if it isn't already there.
|
||||
/// hive-c0re manages hm1nd end-to-end (Phase 8 follow-up): operators no
|
||||
/// longer declare `containers.hm1nd` in their host NixOS config. Bypasses
|
||||
/// the approval queue — manager is required infrastructure. Idempotent.
|
||||
pub async fn ensure_manager(coord: &Arc<Coordinator>) -> Result<()> {
|
||||
let existing = lifecycle::list().await.unwrap_or_default();
|
||||
let current_rev = current_flake_rev(&coord.hyperhive_flake);
|
||||
if existing.iter().any(|c| c == MANAGER_NAME) {
|
||||
// Container exists already. If it predates the unified lifecycle
|
||||
// (no applied flake on disk) we must rebuild — otherwise it's
|
||||
// running whatever the host-declarative config was at create
|
||||
// time, with a wrong systemd unit and port.
|
||||
let applied_flake = Coordinator::agent_applied_dir(MANAGER_NAME).join("flake.nix");
|
||||
if !applied_flake.exists()
|
||||
&& let Some(rev) = current_rev.as_ref()
|
||||
{
|
||||
tracing::warn!(
|
||||
"manager container exists but no applied flake — forcing rebuild to migrate"
|
||||
);
|
||||
let coord_clone = coord.clone();
|
||||
if let Err(e) = rebuild_agent(&coord_clone, MANAGER_NAME, rev.as_str(), None).await {
|
||||
tracing::warn!(error = ?e, "manager migration rebuild failed");
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("manager container already present");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!("manager container missing — spawning");
|
||||
let runtime = coord.ensure_runtime(MANAGER_NAME)?;
|
||||
let proposed = Coordinator::agent_proposed_dir(MANAGER_NAME);
|
||||
let applied = Coordinator::agent_applied_dir(MANAGER_NAME);
|
||||
let claude_dir = Coordinator::agent_claude_dir(MANAGER_NAME);
|
||||
let notes_dir = Coordinator::agent_notes_dir(MANAGER_NAME);
|
||||
lifecycle::spawn(
|
||||
MANAGER_NAME,
|
||||
&coord.hyperhive_flake,
|
||||
&runtime,
|
||||
&proposed,
|
||||
&applied,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
&coord.operator_pronouns,
|
||||
&coord.context_window_tokens,
|
||||
)
|
||||
.await?;
|
||||
if let Some(rev) = current_rev {
|
||||
let _ = std::fs::write(rev_marker_path(MANAGER_NAME), &rev);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild every container on startup. Sequential to avoid nix-store sqlite
|
||||
/// races and keep logs readable. Returns Ok even if some rebuilds failed.
|
||||
pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
|
||||
// Bump meta's hyperhive input up-front so per-agent rebuilds build
|
||||
// against the latest base. Non-fatal on failure.
|
||||
if let Err(e) = crate::meta::lock_update_hyperhive().await {
|
||||
tracing::warn!(error = ?e, "auto-update: meta lock_update_hyperhive failed");
|
||||
}
|
||||
|
||||
let containers = match lifecycle::list().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "auto-update: nixos-container list failed");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let _current_rev = current_flake_rev(&coord.hyperhive_flake).unwrap_or_default();
|
||||
|
||||
tracing::info!(agents = containers.len(), "auto-update: queueing all on startup");
|
||||
for container in containers {
|
||||
let logical = if container == MANAGER_NAME {
|
||||
Some(MANAGER_NAME.to_owned())
|
||||
} else {
|
||||
container.strip_prefix(AGENT_PREFIX).map(str::to_owned)
|
||||
};
|
||||
let Some(name) = logical else { continue };
|
||||
coord.rebuild_queue.enqueue(
|
||||
crate::rebuild_queue::QueueKind::Rebuild,
|
||||
name,
|
||||
crate::rebuild_queue::QueueSource::AutoUpdate,
|
||||
"startup sweep".to_owned(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
coord.emit_rebuild_queue_snapshot();
|
||||
Ok(())
|
||||
}
|
||||
1133
hive-c0re/src/broker.rs
Normal file
1133
hive-c0re/src/broker.rs
Normal file
File diff suppressed because it is too large
Load diff
27
hive-c0re/src/client.rs
Normal file
27
hive-c0re/src/client.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use hive_sh4re::{HostRequest, HostResponse};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
pub async fn request(socket: &Path, req: HostRequest) -> Result<HostResponse> {
|
||||
let stream = UnixStream::connect(socket)
|
||||
.await
|
||||
.with_context(|| format!("connect to {}", socket.display()))?;
|
||||
let (read, mut write) = stream.into_split();
|
||||
|
||||
let mut payload = serde_json::to_string(&req)?;
|
||||
payload.push('\n');
|
||||
write.write_all(payload.as_bytes()).await?;
|
||||
write.flush().await?;
|
||||
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await?;
|
||||
if line.is_empty() {
|
||||
bail!("server closed connection without responding");
|
||||
}
|
||||
let resp: HostResponse = serde_json::from_str(line.trim())?;
|
||||
Ok(resp)
|
||||
}
|
||||
412
hive-c0re/src/container_view.rs
Normal file
412
hive-c0re/src/container_view.rs
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
//! `ContainerView` + the snapshot builder that turns
|
||||
//! `nixos-container list` (plus per-agent state on disk) into the row
|
||||
//! shape the dashboard renders. Extracted from `dashboard.rs` so the
|
||||
//! coordinator's rescan-and-emit helper can build the same view and
|
||||
//! diff against the last snapshot to fire
|
||||
//! `ContainerStateChanged` / `ContainerRemoved` events.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
|
||||
|
||||
/// An agent-declared extra navigation link surfaced on the dashboard card.
|
||||
/// Written by the `hive-dashboard-links` NixOS oneshot into
|
||||
/// `{state_dir}/hyperhive-dashboard-links.json` and read by `build_all`.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct DashboardLink {
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub icon: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, PartialEq, Eq, Debug)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct ContainerView {
|
||||
/// Logical agent name (no `h-` prefix). Used in action URLs.
|
||||
pub name: String,
|
||||
/// Container name as nixos-container sees it (`h-foo`, `hm1nd`).
|
||||
pub container: String,
|
||||
pub is_manager: bool,
|
||||
pub port: u16,
|
||||
pub running: bool,
|
||||
pub needs_update: bool,
|
||||
pub needs_login: bool,
|
||||
/// First 12 chars of the sha the meta flake currently has locked
|
||||
/// for this agent's input.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deployed_sha: Option<String>,
|
||||
/// Count of this agent's pending reminders. Computed during
|
||||
/// `build_all` via `Broker::count_pending_reminders_for`; the
|
||||
/// dashboard renders a small chip when > 0. Updates with the
|
||||
/// 10s `crash_watch` rescan + every container mutation site;
|
||||
/// not real-time on remind/cancel-reminder but close enough.
|
||||
#[serde(default)]
|
||||
pub pending_reminders: u64,
|
||||
/// Context-window size (prompt tokens) from the agent's most recent
|
||||
/// completed turn, read directly from the turn-stats `SQLite`.
|
||||
/// `None` when the file is absent or the agent has no turns yet.
|
||||
/// Stale by up to one crash-watch cycle (~10s); good enough for
|
||||
/// the "which agent is close to the window?" dashboard glance.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ctx_tokens: Option<u64>,
|
||||
/// Context-window size (tokens) for the model this agent ran on its
|
||||
/// most recent turn — the model name from the last turn-stats row
|
||||
/// resolved against the host's per-model `contextWindowTokens`
|
||||
/// config. Lets the dashboard derive the ctx badge thresholds
|
||||
/// (75% / 50% of the window, matching the harness compaction
|
||||
/// watermarks) instead of hardcoding them. `None` when the agent
|
||||
/// has no turns yet or no config key matches the model. (issue #66)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context_window_tokens: Option<u64>,
|
||||
/// True while the harness is parked after an API rate-limit response.
|
||||
/// Detected via the sentinel file `{state_dir}/hyperhive-rate-limited`
|
||||
/// that the harness writes in `Bus::emit_status("rate_limited")` and
|
||||
/// removes when it resumes. Stale by up to one crash-watch cycle.
|
||||
#[serde(default)]
|
||||
pub rate_limited: bool,
|
||||
/// Extra navigation links declared by the agent via
|
||||
/// `hyperhive.dashboardLinks` in `agent.nix`. Written to
|
||||
/// `{state_dir}/hyperhive-dashboard-links.json` by the
|
||||
/// `hive-dashboard-links` oneshot at container boot. Empty when
|
||||
/// the file is absent or the agent declares no links.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extra_links: Vec<DashboardLink>,
|
||||
/// Free-text status set by the agent via `mcp__hyperhive__set_status`.
|
||||
/// Persisted to `{state_dir}/hyperhive-status`. `None` when the file
|
||||
/// is absent or empty — the agent hasn't set one yet.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status_text: Option<String>,
|
||||
/// Unix timestamp (seconds since epoch) when the status was last written.
|
||||
/// Derived from the `hyperhive-status` file's mtime. `None` when no
|
||||
/// status is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status_set_at: Option<i64>,
|
||||
/// Name of this agent's parent in the agent hierarchy (#361). `None`
|
||||
/// marks the agent as root-level; the dashboard renders it without
|
||||
/// indentation. Sourced from `meta/topology.json` (single source of
|
||||
/// truth, hive-c0re-owned) — NOT from per-agent agent.nix, because
|
||||
/// an agent shouldn't be able to unilaterally declare its own place
|
||||
/// in the tree.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the full container list. Wraps `lifecycle::list()` and
|
||||
/// resolves every per-agent attribute the dashboard surfaces.
|
||||
pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
||||
let raw = lifecycle::list().await.unwrap_or_default();
|
||||
let locked = read_meta_locked_revs();
|
||||
// Pull the topology map once and look up each agent's parent below.
|
||||
// Empty / absent topology.json → every agent root-level (matches
|
||||
// the pre-#361 status quo for fresh installs).
|
||||
let topology = crate::topology::read();
|
||||
let mut out = Vec::new();
|
||||
for c in &raw {
|
||||
let (logical, is_manager) = if c == MANAGER_NAME {
|
||||
(MANAGER_NAME.to_owned(), true)
|
||||
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
|
||||
(n.to_owned(), false)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let deployed_full = locked.get(&format!("agent-{logical}")).map(std::string::String::as_str);
|
||||
let needs_update = crate::auto_update::agent_config_pending(&logical, deployed_full);
|
||||
let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned());
|
||||
// Recipient name the broker uses for this agent — sub-agents
|
||||
// are addressed by logical name, the manager by the
|
||||
// MANAGER_AGENT constant. Mirrors the rest of the broker
|
||||
// surface so the count matches what `mcp__hyperhive__remind`
|
||||
// queued.
|
||||
let reminder_recipient = if is_manager {
|
||||
hive_sh4re::MANAGER_AGENT
|
||||
} else {
|
||||
logical.as_str()
|
||||
};
|
||||
let pending_reminders = coord
|
||||
.broker
|
||||
.count_pending_reminders_for(reminder_recipient)
|
||||
.unwrap_or(0);
|
||||
let extra_links = read_dashboard_links(&logical);
|
||||
let parent = topology.get(&logical).cloned().flatten();
|
||||
let running = lifecycle::is_running(&logical).await;
|
||||
// Live-only fields (#432) — only meaningful while the harness
|
||||
// is up. When the container is stopped, sentinel files +
|
||||
// turn-stats rows + the on-disk status blob are all stale
|
||||
// snapshots from before the stop, so we clear them here
|
||||
// rather than letting the dashboard / `get_agent_meta` surface
|
||||
// misleading values. Static / declared fields (extra_links,
|
||||
// deployed_sha, pending_reminders, needs_update, parent) stay
|
||||
// populated regardless of run state.
|
||||
let (needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at) =
|
||||
if running {
|
||||
// needs_login fires when EITHER the claude session dir is
|
||||
// missing (boot-time / fresh container) OR the harness wrote
|
||||
// the auth-failed sentinel because a turn hit 401 (#419). The
|
||||
// manager has its own session lifecycle and never participates
|
||||
// in needs_login.
|
||||
let needs_login = !is_manager
|
||||
&& (!claude_has_session(&Coordinator::agent_claude_dir(&logical))
|
||||
|| auth_failed_sentinel(&logical));
|
||||
let last_turn = read_last_turn(&logical);
|
||||
let ctx_tokens = last_turn.as_ref().map(|(toks, _)| *toks);
|
||||
let context_window_tokens = last_turn
|
||||
.as_ref()
|
||||
.and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens));
|
||||
let rate_limited = is_rate_limited(&logical);
|
||||
let (status_text, status_set_at) = read_status(&logical);
|
||||
(needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at)
|
||||
} else {
|
||||
(false, None, None, false, None, None)
|
||||
};
|
||||
out.push(ContainerView {
|
||||
port: lifecycle::agent_web_port(&logical),
|
||||
running,
|
||||
container: c.clone(),
|
||||
name: logical,
|
||||
is_manager,
|
||||
needs_update,
|
||||
needs_login,
|
||||
deployed_sha,
|
||||
pending_reminders,
|
||||
ctx_tokens,
|
||||
context_window_tokens,
|
||||
rate_limited,
|
||||
extra_links,
|
||||
status_text,
|
||||
status_set_at,
|
||||
parent,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true
|
||||
/// if the agent's bound `~/.claude/` dir on disk contains any regular
|
||||
/// file. Reads each `build_all()` so a login driven from the agent's
|
||||
/// own web UI reflects on the next snapshot.
|
||||
pub fn claude_has_session(dir: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
||||
}
|
||||
|
||||
/// Read agent-declared extra dashboard links from
|
||||
/// `{state_dir}/hyperhive-dashboard-links.json`. Returns an empty vec when
|
||||
/// the file is absent, empty, or unparseable — best-effort, never panics.
|
||||
fn read_dashboard_links(name: &str) -> Vec<DashboardLink> {
|
||||
let path = Coordinator::agent_notes_dir(name).join("hyperhive-dashboard-links.json");
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(t) if !t.trim().is_empty() => t,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
serde_json::from_str::<Vec<DashboardLink>>(&text).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns true if the agent's harness is currently parked after an API
|
||||
/// rate-limit response. Detected via the sentinel file written by
|
||||
/// `hive_ag3nt::events::Bus::emit_status("rate_limited")`.
|
||||
fn is_rate_limited(name: &str) -> bool {
|
||||
Coordinator::agent_notes_dir(name)
|
||||
.join("hyperhive-rate-limited")
|
||||
.exists()
|
||||
}
|
||||
|
||||
/// True when the harness wrote `{state_dir}/hyperhive-needs-login`
|
||||
/// after a 401 mid-turn. Lets the dashboard surface `needs_login` for
|
||||
/// agents whose `/root/.claude/` dir still exists (so
|
||||
/// `claude_has_session` returns true) but whose OAuth credentials
|
||||
/// inside it have actually expired (#419).
|
||||
fn auth_failed_sentinel(name: &str) -> bool {
|
||||
Coordinator::agent_notes_dir(name)
|
||||
.join("hyperhive-needs-login")
|
||||
.exists()
|
||||
}
|
||||
|
||||
/// Read the agent's free-text status and the Unix timestamp when it was last set
|
||||
/// (derived from the file's mtime). Returns `(None, None)` when the file is absent
|
||||
/// or empty. `pub` so `agent_server` and `manager_server` can populate `AgentMeta`.
|
||||
///
|
||||
/// NB: callers building `AgentMeta` for a *stopped* container should
|
||||
/// clear the result — the on-disk status is a stale snapshot from
|
||||
/// before the stop (#432). Use `read_agent_status_live` for that.
|
||||
pub fn read_agent_status(name: &str) -> (Option<String>, Option<i64>) {
|
||||
let path = Coordinator::agent_notes_dir(name).join("hyperhive-status");
|
||||
let meta = std::fs::metadata(&path).ok();
|
||||
let s = std::fs::read_to_string(&path).ok();
|
||||
let text = s.as_deref().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned);
|
||||
let mtime = meta.and_then(|m| {
|
||||
m.modified().ok().and_then(|t| {
|
||||
t.duration_since(std::time::UNIX_EPOCH).ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
})
|
||||
});
|
||||
if text.is_none() { (None, None) } else { (text, mtime) }
|
||||
}
|
||||
|
||||
fn read_status(name: &str) -> (Option<String>, Option<i64>) {
|
||||
read_agent_status(name)
|
||||
}
|
||||
|
||||
/// Wraps `read_agent_status` with the same "stopped containers have
|
||||
/// stale state" gate `build_all` uses (#432). Returns
|
||||
/// `(None, None, false)` when the container isn't running so callers
|
||||
/// don't have to know about the sentinel rules — they just hand back
|
||||
/// what we give them.
|
||||
///
|
||||
/// Returned tuple is `(status_text, status_set_at, running)`. The
|
||||
/// `name` argument is the broker-side recipient — `MANAGER_AGENT` for
|
||||
/// the manager, the logical agent name otherwise — so callers can
|
||||
/// reuse the same string they used to look the agent up.
|
||||
pub async fn read_agent_status_live(name: &str) -> (Option<String>, Option<i64>, bool) {
|
||||
// The lifecycle helper wants the on-disk name (`hm1nd` for the
|
||||
// manager, the bare logical name for sub-agents) and internally
|
||||
// adds the `h-` prefix. Map the broker-side `MANAGER_AGENT`
|
||||
// sentinel back to the lifecycle name here so callers don't have
|
||||
// to bother.
|
||||
let lifecycle_name = if name == hive_sh4re::MANAGER_AGENT {
|
||||
lifecycle::MANAGER_NAME
|
||||
} else {
|
||||
name
|
||||
};
|
||||
if !lifecycle::is_running(lifecycle_name).await {
|
||||
return (None, None, false);
|
||||
}
|
||||
let (text, set_at) = read_agent_status(name);
|
||||
(text, set_at, true)
|
||||
}
|
||||
|
||||
/// Read the agent's most recent completed turn from its turn-stats
|
||||
/// `SQLite`: the context-window size (prompt tokens) and the model name.
|
||||
/// Returns `None` when the file is absent or has no rows. Best-effort
|
||||
/// — any database error silently yields `None` so a missing or
|
||||
/// corrupt file never blocks `build_all`.
|
||||
///
|
||||
/// Context tokens sum the prompt-side fields (`last_input_tokens`,
|
||||
/// `last_cache_read_input_tokens`, `last_cache_creation_input_tokens`),
|
||||
/// mirroring `hive_ag3nt::events::TokenUsage::context_tokens`.
|
||||
fn read_last_turn(name: &str) -> Option<(u64, String)> {
|
||||
let path = Coordinator::agent_notes_dir(name).join("hyperhive-turn-stats.sqlite");
|
||||
let conn = Connection::open_with_flags(
|
||||
&path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
)
|
||||
.ok()?;
|
||||
conn.query_row(
|
||||
"SELECT last_input_tokens + last_cache_read_input_tokens + last_cache_creation_input_tokens, model \
|
||||
FROM turn_stats ORDER BY started_at DESC LIMIT 1",
|
||||
[],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|(toks, model)| Some((u64::try_from(toks).ok()?, model)))
|
||||
}
|
||||
|
||||
/// Resolve a model name to its context-window size using the host's
|
||||
/// per-model `contextWindowTokens` config. Mirrors the harness's
|
||||
/// `events::context_window_tokens` substring match: the first config
|
||||
/// key (lowercased, non-empty) that is a substring of the lowercased
|
||||
/// model name wins. `None` when nothing matches.
|
||||
fn resolve_ctx_window(model: &str, per_model: &HashMap<String, u64>) -> Option<u64> {
|
||||
let m = model.to_ascii_lowercase();
|
||||
per_model
|
||||
.iter()
|
||||
.find(|(key, _)| {
|
||||
let k = key.to_ascii_lowercase();
|
||||
!k.is_empty() && m.contains(&k)
|
||||
})
|
||||
.map(|(_, &tokens)| tokens)
|
||||
}
|
||||
|
||||
/// Map of `agent-<n>` → locked sha from meta's flake.lock. Used to
|
||||
/// render the `deployed:<sha12>` chip per container row.
|
||||
fn read_meta_locked_revs() -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else {
|
||||
return out;
|
||||
};
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
|
||||
return out;
|
||||
};
|
||||
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
|
||||
return out;
|
||||
};
|
||||
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
|
||||
return out;
|
||||
};
|
||||
let Some(root_inputs) = nodes
|
||||
.get(root_name)
|
||||
.and_then(|n| n.get("inputs"))
|
||||
.and_then(|v| v.as_object())
|
||||
else {
|
||||
return out;
|
||||
};
|
||||
for alias in root_inputs.keys() {
|
||||
let target_name = match root_inputs.get(alias) {
|
||||
Some(serde_json::Value::String(s)) => s.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(rev) = nodes
|
||||
.get(&target_name)
|
||||
.and_then(|n| n.get("locked"))
|
||||
.and_then(|v| v.get("rev"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
out.insert(alias.clone(), rev.to_owned());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_ctx_window;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn cfg() -> HashMap<String, u64> {
|
||||
[
|
||||
("haiku".to_owned(), 200_000),
|
||||
("sonnet".to_owned(), 1_000_000),
|
||||
("opus".to_owned(), 1_000_000),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_family_substring() {
|
||||
assert_eq!(resolve_ctx_window("claude-3-5-haiku-20241022", &cfg()), Some(200_000));
|
||||
assert_eq!(resolve_ctx_window("claude-sonnet-4-5", &cfg()), Some(1_000_000));
|
||||
assert_eq!(resolve_ctx_window("claude-opus-4-1", &cfg()), Some(1_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_is_case_insensitive() {
|
||||
assert_eq!(resolve_ctx_window("Claude-Sonnet-4", &cfg()), Some(1_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_model_yields_none() {
|
||||
assert_eq!(resolve_ctx_window("some-other-llm", &cfg()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_config_yields_none() {
|
||||
assert_eq!(resolve_ctx_window("claude-3-5-haiku", &HashMap::new()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_key_is_skipped() {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(String::new(), 999);
|
||||
assert_eq!(resolve_ctx_window("claude-3-5-haiku", &m), None);
|
||||
}
|
||||
}
|
||||
803
hive-c0re/src/coordinator.rs
Normal file
803
hive-c0re/src/coordinator.rs
Normal file
|
|
@ -0,0 +1,803 @@
|
|||
//! Runtime state + config shared between the host admin socket, the manager
|
||||
//! socket, and the per-agent sockets: the broker, configured `agent_flake`,
|
||||
//! and the map of registered agent sockets.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::sync::{broadcast, watch};
|
||||
|
||||
use crate::agent_server::{self, AgentSocket};
|
||||
use crate::approvals::Approvals;
|
||||
use crate::broker::Broker;
|
||||
use crate::container_view::{self, ContainerView};
|
||||
use crate::dashboard_events::DashboardEvent;
|
||||
use crate::operator_questions::OperatorQuestions;
|
||||
|
||||
/// Capacity of the dashboard event channel. Slow browser subscribers
|
||||
/// (idle tab, throttled connection) drop frames past this — that's
|
||||
/// fine, the seq dedupe makes a reconnect resync safe.
|
||||
const DASHBOARD_CHANNEL: usize = 256;
|
||||
|
||||
const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
|
||||
const MANAGER_RUNTIME_ROOT: &str = "/run/hyperhive/manager";
|
||||
/// Manager-editable per-agent config repos. Bind-mounted RW into the manager
|
||||
/// container as `/agents/<name>/`. Hive-c0re only writes to these on first
|
||||
/// spawn (initial commit); after that it's manager-only.
|
||||
const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents";
|
||||
/// Hive-c0re-only authoritative per-agent config repos. Containers build from
|
||||
/// these. Manager has no filesystem access; the only way to update is via
|
||||
/// `request_apply_commit` + user approval.
|
||||
const APPLIED_STATE_ROOT: &str = "/var/lib/hyperhive/applied";
|
||||
|
||||
pub struct Coordinator {
|
||||
pub broker: Arc<Broker>,
|
||||
pub approvals: Arc<Approvals>,
|
||||
pub questions: Arc<OperatorQuestions>,
|
||||
/// Scheduled-prompts queue (#444). One sqlite connection,
|
||||
/// internal mutex; the worker drains due rows and the manager
|
||||
/// handlers insert / cancel through the same handle.
|
||||
pub scheduled_prompts: Arc<crate::scheduled_prompts::ScheduledPrompts>,
|
||||
/// URL of the hyperhive flake (no fragment). Inlined into per-agent
|
||||
/// `flake.nix` files as `inputs.hyperhive.url`.
|
||||
pub hyperhive_flake: String,
|
||||
/// TCP port the host's hive-c0re dashboard listens on. Inlined into
|
||||
/// each per-agent flake so the agent's web UI can build the right
|
||||
/// rebuild-button URL pointing back at the dashboard.
|
||||
pub dashboard_port: u16,
|
||||
/// Operator pronouns (free text) — `she/her` by default, set via
|
||||
/// the NixOS module option `services.hive-c0re.operatorPronouns`.
|
||||
/// Reaches each container as the `HIVE_OPERATOR_PRONOUNS` env var
|
||||
/// (injected into systemd.services.<harness>.environment by the
|
||||
/// meta flake); the harness substitutes it into the agent /
|
||||
/// manager system prompt at boot.
|
||||
pub operator_pronouns: String,
|
||||
/// Per-model context-window sizes in tokens. Set via the host-level
|
||||
/// `services.hive-c0re.contextWindowTokens` NixOS option; injected
|
||||
/// into each container as `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>`
|
||||
/// by the meta flake renderer. The harness uses these to derive
|
||||
/// compaction / auto-reset watermarks and exposes the active value
|
||||
/// on `/api/state` as `context_window_tokens`.
|
||||
pub context_window_tokens: std::collections::HashMap<String, u64>,
|
||||
agents: Mutex<HashMap<String, AgentSocket>>,
|
||||
/// Agents whose lifecycle action (currently just spawn) is in flight.
|
||||
/// Read by the dashboard to render a spinner; cleared when the action
|
||||
/// resolves (success or failure).
|
||||
transient: Mutex<HashMap<String, TransientState>>,
|
||||
/// Tombstone for transients that have JUST been cleared. The
|
||||
/// crash watcher polls every 10s and would race the
|
||||
/// drop-clears-immediately path of `TransientGuard`: an operator
|
||||
/// kill / restart sets `Stopping` → runs `nixos-container stop` →
|
||||
/// drop clears the transient → poll fires next tick and sees the
|
||||
/// container missing-from-running with no active transient →
|
||||
/// spurious "container stopped without an operator action"
|
||||
/// message (closes #425).
|
||||
///
|
||||
/// `clear_transient` stamps the cleared kind here with an
|
||||
/// `Instant`; `recent_transient_within(grace)` returns the set of
|
||||
/// agents whose tombstone is still inside the grace window. Crash
|
||||
/// watcher consults both this and the active map before declaring
|
||||
/// a stop deliberate.
|
||||
recent_transient: Mutex<HashMap<String, (TransientKind, std::time::Instant)>>,
|
||||
/// Unified wire-facing event channel feeding the dashboard SSE
|
||||
/// stream. Carries broker messages (mirrored from `broker.subscribe`
|
||||
/// by the forwarder task in `main.rs`) and dashboard-only mutation
|
||||
/// events (approval added/resolved, question added/answered, etc.).
|
||||
/// Snapshot endpoints capture `event_seq` before reading state so
|
||||
/// the client can dedupe its buffered live traffic against the
|
||||
/// snapshot.
|
||||
dashboard_events: broadcast::Sender<DashboardEvent>,
|
||||
event_seq: AtomicU64,
|
||||
/// Count of dashboard-triggered `meta-update` runs currently in
|
||||
/// flight. `post_meta_update` returns 200 immediately and does the
|
||||
/// multi-minute `nix flake update` + agent-rebuild ripple in a
|
||||
/// background task, so without this the META INPUTS panel showed no
|
||||
/// sign anything was happening (issue #259). Held via
|
||||
/// `MetaUpdateGuard`; a count > 0 surfaces on `/api/state` as
|
||||
/// `meta_update_running` and via the `MetaUpdateRunning` event.
|
||||
meta_updates_active: AtomicU64,
|
||||
/// Last container snapshot seen by `rescan_containers_and_emit`,
|
||||
/// keyed by `ContainerView.name`. The rescan diffs a fresh
|
||||
/// `container_view::build_all` against this map and emits one
|
||||
/// `ContainerStateChanged` per added/changed row and one
|
||||
/// `ContainerRemoved` per disappeared row. Async — guarded by a
|
||||
/// tokio mutex so the rescan can `await` `lifecycle::list` /
|
||||
/// `is_running` without blocking other coordinator paths.
|
||||
last_containers: tokio::sync::Mutex<HashMap<String, ContainerView>>,
|
||||
/// Global rebuild queue. Every long-running container/meta op
|
||||
/// (rebuild, meta-update, first-spawn) goes through this queue so
|
||||
/// hive-c0re runs at most one at a time and the dashboard can
|
||||
/// render a single ordered view of pending + running work. See
|
||||
/// `rebuild_queue.rs` for the dedup rules + history retention.
|
||||
pub rebuild_queue: Arc<crate::rebuild_queue::RebuildQueue>,
|
||||
/// Shutdown signal broadcast to all background tasks. Sending
|
||||
/// `true` asks every loop to exit after its current work item.
|
||||
/// Use `shutdown_rx()` to subscribe; `request_shutdown()` to fire.
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
/// Per-agent in-progress state that the dashboard surfaces between approve
|
||||
/// click and container ready.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TransientState {
|
||||
pub kind: TransientKind,
|
||||
pub since: std::time::Instant,
|
||||
}
|
||||
|
||||
/// RAII handle returned by `Coordinator::transient_guard`. Cleared on
|
||||
/// drop — including drop-via-cancellation, the path that bare
|
||||
/// `set_transient` / `clear_transient` pairs leaked through. Holds an
|
||||
/// `Arc<Coordinator>` so the guard is freely returnable / movable.
|
||||
pub struct TransientGuard {
|
||||
coord: Arc<Coordinator>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl Drop for TransientGuard {
|
||||
fn drop(&mut self) {
|
||||
self.coord.clear_transient(&self.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard for the `meta-update` in-progress flag, held for the
|
||||
/// duration of a `run_meta_update` background task. Created by
|
||||
/// `Coordinator::meta_update_guard`. Drop decrements the active-run
|
||||
/// count; the count crossing back to 0 emits
|
||||
/// `MetaUpdateRunning { running: false }`, so a concurrent pair of
|
||||
/// updates only flips the dashboard flag once.
|
||||
pub struct MetaUpdateGuard {
|
||||
coord: Arc<Coordinator>,
|
||||
}
|
||||
|
||||
impl Drop for MetaUpdateGuard {
|
||||
fn drop(&mut self) {
|
||||
if self
|
||||
.coord
|
||||
.meta_updates_active
|
||||
.fetch_sub(1, Ordering::SeqCst)
|
||||
== 1
|
||||
{
|
||||
self.coord
|
||||
.emit_dashboard_event(DashboardEvent::MetaUpdateRunning {
|
||||
seq: self.coord.next_seq(),
|
||||
running: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum TransientKind {
|
||||
/// `lifecycle::spawn` is running (nixos-container create + update + start).
|
||||
Spawning,
|
||||
/// `lifecycle::start` is running.
|
||||
Starting,
|
||||
/// `lifecycle::kill` is running.
|
||||
Stopping,
|
||||
/// `lifecycle::restart` is running.
|
||||
Restarting,
|
||||
/// `lifecycle::rebuild` is running (nixos-container update).
|
||||
Rebuilding,
|
||||
/// `actions::destroy` is running.
|
||||
Destroying,
|
||||
}
|
||||
|
||||
impl TransientKind {
|
||||
/// Wire/UI label. Matches the strings the dashboard already
|
||||
/// renders in the transient spinner.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
TransientKind::Spawning => "spawning",
|
||||
TransientKind::Starting => "starting",
|
||||
TransientKind::Stopping => "stopping",
|
||||
TransientKind::Restarting => "restarting",
|
||||
TransientKind::Rebuilding => "rebuilding",
|
||||
TransientKind::Destroying => "destroying",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Coordinator {
|
||||
pub fn open(
|
||||
db_path: &Path,
|
||||
hyperhive_flake: String,
|
||||
dashboard_port: u16,
|
||||
operator_pronouns: String,
|
||||
context_window_tokens: std::collections::HashMap<String, u64>,
|
||||
) -> Result<Self> {
|
||||
let broker = Broker::open(db_path).context("open broker")?;
|
||||
let approvals = Approvals::open(db_path).context("open approvals")?;
|
||||
let questions = OperatorQuestions::open(db_path).context("open operator_questions")?;
|
||||
let scheduled_prompts = crate::scheduled_prompts::ScheduledPrompts::open(db_path)
|
||||
.context("open scheduled_prompts")?;
|
||||
let (dashboard_events, _) = broadcast::channel(DASHBOARD_CHANNEL);
|
||||
let (shutdown_tx, _) = watch::channel(false);
|
||||
Ok(Self {
|
||||
broker: Arc::new(broker),
|
||||
approvals: Arc::new(approvals),
|
||||
questions: Arc::new(questions),
|
||||
scheduled_prompts: Arc::new(scheduled_prompts),
|
||||
hyperhive_flake,
|
||||
dashboard_port,
|
||||
operator_pronouns,
|
||||
context_window_tokens,
|
||||
agents: Mutex::new(HashMap::new()),
|
||||
transient: Mutex::new(HashMap::new()),
|
||||
recent_transient: Mutex::new(HashMap::new()),
|
||||
dashboard_events,
|
||||
event_seq: AtomicU64::new(0),
|
||||
meta_updates_active: AtomicU64::new(0),
|
||||
last_containers: tokio::sync::Mutex::new(HashMap::new()),
|
||||
rebuild_queue: Arc::new(crate::rebuild_queue::RebuildQueue::new()),
|
||||
shutdown_tx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Emit a `RebuildQueueChanged` snapshot event. Called from the
|
||||
/// queue mutation helpers (`enqueue` / `finish` / `cancel`-adjacent
|
||||
/// wrappers below) and the worker so every state transition
|
||||
/// surfaces on the dashboard without extra plumbing.
|
||||
pub fn emit_rebuild_queue_snapshot(self: &Arc<Self>) {
|
||||
let queue = self.rebuild_queue.snapshot();
|
||||
self.emit_dashboard_event(DashboardEvent::RebuildQueueChanged {
|
||||
seq: self.next_seq(),
|
||||
queue,
|
||||
});
|
||||
}
|
||||
|
||||
/// Update the `step` label on a running queue entry and (if it
|
||||
/// actually changed) re-emit the queue snapshot so the dashboard
|
||||
/// renders the new phase. Returns `true` when the label was new
|
||||
/// and an emit fired, mostly for tracing/logging callers; safe to
|
||||
/// ignore. No-op when `id` is `None` (e.g. callers that aren't
|
||||
/// running from the queue worker) or when the row isn't `Running`.
|
||||
pub fn set_queue_step(self: &Arc<Self>, id: Option<u64>, step: &str) {
|
||||
let Some(id) = id else { return };
|
||||
if self.rebuild_queue.set_step(id, step) {
|
||||
self.emit_rebuild_queue_snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to the shutdown watch channel. Background tasks call
|
||||
/// this at spawn time and break their loop when the receiver
|
||||
/// transitions to `true` (via `Coordinator::request_shutdown`).
|
||||
/// A closed channel (i.e. the Coordinator was dropped) also
|
||||
/// signals tasks to exit.
|
||||
pub fn shutdown_rx(&self) -> watch::Receiver<bool> {
|
||||
self.shutdown_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Signal all background tasks to exit cleanly. The tasks break
|
||||
/// out of their poll loop after completing their current work item.
|
||||
/// Best-effort — does nothing if all receivers have already been
|
||||
/// dropped (e.g. process is already mid-shutdown).
|
||||
pub fn request_shutdown(&self) {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
}
|
||||
|
||||
/// Subscribe to the unified dashboard event channel. Used by the
|
||||
/// `/dashboard/stream` SSE handler and by the broker-to-dashboard
|
||||
/// forwarder task.
|
||||
pub fn dashboard_subscribe(&self) -> broadcast::Receiver<DashboardEvent> {
|
||||
self.dashboard_events.subscribe()
|
||||
}
|
||||
|
||||
/// Stamp the next sequence number. Each emission of a
|
||||
/// `DashboardEvent` should fill its `seq` with `next_seq()` so the
|
||||
/// frame the wire carries is the one the client uses to dedupe.
|
||||
pub fn next_seq(&self) -> u64 {
|
||||
self.event_seq.fetch_add(1, Ordering::SeqCst) + 1
|
||||
}
|
||||
|
||||
/// Current high-water seq. Snapshot endpoints read this *before*
|
||||
/// gathering state so the (snapshot.seq, snapshot) pair satisfies:
|
||||
/// any frame with `seq > snapshot.seq` is post-snapshot. The seq
|
||||
/// captured here may grow during snapshot construction — clients
|
||||
/// may double-apply such events, which renderers must tolerate.
|
||||
pub fn current_seq(&self) -> u64 {
|
||||
self.event_seq.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Broadcast a freshly-built `DashboardEvent` (caller fills `seq`
|
||||
/// via `next_seq()`). Returns silently when there are no
|
||||
/// subscribers — the dashboard channel is best-effort presentation
|
||||
/// plumbing, not a delivery guarantee.
|
||||
pub fn emit_dashboard_event(&self, event: DashboardEvent) {
|
||||
let _ = self.dashboard_events.send(event);
|
||||
}
|
||||
|
||||
/// Mark a `meta-update` as in flight and return an RAII guard that
|
||||
/// clears it on drop (including drop-via-panic). The first
|
||||
/// concurrent run emits `MetaUpdateRunning { running: true }`; the
|
||||
/// last one to finish emits `running: false`. The dashboard's META
|
||||
/// INPUTS panel reads the flag to show a disabled "updating…"
|
||||
/// state while the lock bump + rebuild ripple runs (issue #259).
|
||||
pub fn meta_update_guard(self: &Arc<Self>) -> MetaUpdateGuard {
|
||||
if self.meta_updates_active.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
self.emit_dashboard_event(DashboardEvent::MetaUpdateRunning {
|
||||
seq: self.next_seq(),
|
||||
running: true,
|
||||
});
|
||||
}
|
||||
MetaUpdateGuard {
|
||||
coord: Arc::clone(self),
|
||||
}
|
||||
}
|
||||
|
||||
/// True while at least one dashboard-triggered `meta-update` is
|
||||
/// running. Surfaced on `/api/state` as `meta_update_running` so a
|
||||
/// client that cold-loads mid-update sees the in-progress state.
|
||||
pub fn meta_update_in_progress(&self) -> bool {
|
||||
self.meta_updates_active.load(Ordering::SeqCst) > 0
|
||||
}
|
||||
|
||||
/// Emit `ApprovalAdded` immediately after the row is inserted in
|
||||
/// sqlite. Caller passes the diff text it already computed (or
|
||||
/// `None` for spawn approvals which carry no diff).
|
||||
pub fn emit_approval_added(
|
||||
&self,
|
||||
id: i64,
|
||||
agent: &str,
|
||||
approval_kind: &'static str,
|
||||
sha_short: Option<String>,
|
||||
diff: Option<String>,
|
||||
description: Option<String>,
|
||||
) {
|
||||
self.emit_dashboard_event(DashboardEvent::ApprovalAdded {
|
||||
seq: self.next_seq(),
|
||||
id,
|
||||
agent: agent.to_owned(),
|
||||
approval_kind,
|
||||
sha_short,
|
||||
diff,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
/// Emit `ApprovalResolved` after `mark_approved` / `mark_denied` /
|
||||
/// `mark_failed` lands. `resolved_at` is stamped from the system
|
||||
/// clock here so call sites don't repeat the conversion; if you
|
||||
/// already have an authoritative timestamp from the db update,
|
||||
/// the tiny skew between "row updated" and "event emitted" is
|
||||
/// presentation-only and doesn't matter to clients.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn emit_approval_resolved(
|
||||
&self,
|
||||
id: i64,
|
||||
agent: &str,
|
||||
approval_kind: &'static str,
|
||||
sha_short: Option<String>,
|
||||
status: &'static str,
|
||||
note: Option<String>,
|
||||
description: Option<String>,
|
||||
) {
|
||||
let resolved_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0);
|
||||
self.emit_dashboard_event(DashboardEvent::ApprovalResolved {
|
||||
seq: self.next_seq(),
|
||||
id,
|
||||
agent: agent.to_owned(),
|
||||
approval_kind,
|
||||
sha_short,
|
||||
status,
|
||||
resolved_at,
|
||||
note,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
/// Emit `QuestionAdded` after a question is inserted. Fires for
|
||||
/// both operator-targeted (`target = None`) and peer-to-peer
|
||||
/// (`target = Some(agent)`) threads — the dashboard surfaces
|
||||
/// both, distinguishing visually + offering operator override.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn emit_question_added(
|
||||
&self,
|
||||
id: i64,
|
||||
asker: &str,
|
||||
question: &str,
|
||||
options: &[String],
|
||||
multi: bool,
|
||||
deadline_at: Option<i64>,
|
||||
target: Option<&str>,
|
||||
) {
|
||||
let asked_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0);
|
||||
let question_refs = crate::dashboard::scan_validated_paths(question);
|
||||
self.emit_dashboard_event(DashboardEvent::QuestionAdded {
|
||||
seq: self.next_seq(),
|
||||
id,
|
||||
asker: asker.to_owned(),
|
||||
question: question.to_owned(),
|
||||
options: options.to_vec(),
|
||||
multi,
|
||||
asked_at,
|
||||
deadline_at,
|
||||
target: target.map(str::to_owned),
|
||||
question_refs,
|
||||
});
|
||||
}
|
||||
|
||||
/// Emit `QuestionResolved` when a question transitions to
|
||||
/// answered (operator answer, peer answer, operator override on
|
||||
/// a peer thread, operator cancel, or ttl watchdog). Both
|
||||
/// operator-targeted and peer threads fire so the dashboard's
|
||||
/// derived store can move the row from pending to history.
|
||||
pub fn emit_question_resolved(
|
||||
&self,
|
||||
id: i64,
|
||||
answer: &str,
|
||||
answerer: &str,
|
||||
cancelled: bool,
|
||||
target: Option<&str>,
|
||||
) {
|
||||
let answered_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0);
|
||||
let answer_refs = crate::dashboard::scan_validated_paths(answer);
|
||||
self.emit_dashboard_event(DashboardEvent::QuestionResolved {
|
||||
seq: self.next_seq(),
|
||||
id,
|
||||
answer: answer.to_owned(),
|
||||
answerer: answerer.to_owned(),
|
||||
answered_at,
|
||||
cancelled,
|
||||
target: target.map(str::to_owned),
|
||||
answer_refs,
|
||||
});
|
||||
}
|
||||
|
||||
/// Rebuild the per-container snapshot, diff it against the last
|
||||
/// one cached on `self`, and emit one
|
||||
/// `DashboardEvent::ContainerStateChanged` per added/changed row
|
||||
/// and one `DashboardEvent::ContainerRemoved` per disappeared row.
|
||||
/// Call after any mutation that could affect what
|
||||
/// `nixos-container list` returns or what a row's
|
||||
/// `running` / `needs_update` / `needs_login` / `deployed_sha`
|
||||
/// resolves to — lifecycle ops, destroy, approve (post-spawn),
|
||||
/// rebuild, meta-update, and the crash-watcher's periodic poll.
|
||||
/// Cheap when nothing changed (one `nixos-container list` + a
|
||||
/// `HashMap` diff + zero emits).
|
||||
pub async fn rescan_containers_and_emit(self: &Arc<Self>) {
|
||||
let fresh = container_view::build_all(self).await;
|
||||
let mut last = self.last_containers.lock().await;
|
||||
let mut changed_or_new = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
// Diff into change vs. add.
|
||||
for view in &fresh {
|
||||
match last.get(&view.name) {
|
||||
Some(prev) if prev == view => {} // unchanged
|
||||
_ => changed_or_new.push(view.clone()),
|
||||
}
|
||||
}
|
||||
// Anything in `last` but not in `fresh` is gone.
|
||||
let fresh_names: std::collections::HashSet<&str> =
|
||||
fresh.iter().map(|c| c.name.as_str()).collect();
|
||||
for name in last.keys() {
|
||||
if !fresh_names.contains(name.as_str()) {
|
||||
removed.push(name.clone());
|
||||
}
|
||||
}
|
||||
// Rebuild the cache from the fresh snapshot.
|
||||
last.clear();
|
||||
for c in fresh {
|
||||
last.insert(c.name.clone(), c);
|
||||
}
|
||||
drop(last);
|
||||
for c in changed_or_new {
|
||||
self.emit_dashboard_event(DashboardEvent::ContainerStateChanged {
|
||||
seq: self.next_seq(),
|
||||
container: c,
|
||||
});
|
||||
}
|
||||
for name in removed {
|
||||
self.emit_dashboard_event(DashboardEvent::ContainerRemoved {
|
||||
seq: self.next_seq(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only snapshot of the last cached container view. Used by
|
||||
/// `/api/state` to cold-load page-open clients without re-running
|
||||
/// `nixos-container list` themselves; the
|
||||
/// `rescan_containers_and_emit` calls keep this fresh.
|
||||
pub async fn containers_snapshot(&self) -> Vec<ContainerView> {
|
||||
let last = self.last_containers.lock().await;
|
||||
let mut out: Vec<ContainerView> = last.values().cloned().collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn register_agent(self: &Arc<Self>, name: &str) -> Result<PathBuf> {
|
||||
// Idempotent: drop any existing listener so re-registration (e.g. on rebuild,
|
||||
// or after a hive-c0re restart cleared /run/hyperhive) gets a fresh socket.
|
||||
self.unregister_agent(name);
|
||||
let agent_dir = Self::agent_dir(name);
|
||||
std::fs::create_dir_all(&agent_dir)
|
||||
.with_context(|| format!("create agent dir {}", agent_dir.display()))?;
|
||||
let socket_path = Self::socket_path(name);
|
||||
// Hand the full Coordinator to the per-agent socket — it
|
||||
// needs broker + operator_questions to handle the agent-side
|
||||
// `ask` / `answer` tools, not just the broker.
|
||||
let socket = agent_server::start(name, &socket_path, self.clone())?;
|
||||
self.agents.lock().unwrap().insert(name.to_owned(), socket);
|
||||
Ok(agent_dir)
|
||||
}
|
||||
|
||||
pub fn unregister_agent(&self, name: &str) {
|
||||
if let Some(socket) = self.agents.lock().unwrap().remove(name) {
|
||||
socket.handle.abort();
|
||||
let _ = std::fs::remove_file(&socket.path);
|
||||
}
|
||||
}
|
||||
pub fn list_agents(&self) -> Vec<String> {
|
||||
self.agents.lock().unwrap().keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Mark an agent as in-progress (only one state per agent for now).
|
||||
///
|
||||
/// Prefer `transient_guard` when possible — it auto-clears on drop
|
||||
/// even if the surrounding future is cancelled (HTTP request
|
||||
/// aborted, runtime shutdown mid-rebuild, panic between set and
|
||||
/// clear). The bare `set_transient` / `clear_transient` pair leaks
|
||||
/// the transient on any of those paths and the dashboard then
|
||||
/// shows the agent stuck in "rebuilding…" forever.
|
||||
pub fn set_transient(&self, name: &str, kind: TransientKind) {
|
||||
self.transient.lock().unwrap().insert(
|
||||
name.to_owned(),
|
||||
TransientState {
|
||||
kind,
|
||||
since: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
// Live-update dashboards. `since_unix` is wall-clock so the
|
||||
// browser can tick "Ns spawning…" without polling. The
|
||||
// intra-process map keeps using `Instant` for monotonicity.
|
||||
let since_unix = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0);
|
||||
self.emit_dashboard_event(DashboardEvent::TransientSet {
|
||||
seq: self.next_seq(),
|
||||
name: name.to_owned(),
|
||||
transient_kind: kind.as_str(),
|
||||
since_unix,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn clear_transient(&self, name: &str) {
|
||||
let removed = self.transient.lock().unwrap().remove(name);
|
||||
if let Some(state) = removed {
|
||||
// Stamp the tombstone so the crash watcher can still see
|
||||
// "operator kicked this off recently" on its next 10s poll
|
||||
// — without this, the clear-then-poll race produced a
|
||||
// spurious ContainerCrash on every operator stop/restart
|
||||
// (#425). Old entries get reaped lazily on read so the map
|
||||
// doesn't grow unbounded.
|
||||
self.recent_transient
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(name.to_owned(), (state.kind, std::time::Instant::now()));
|
||||
self.emit_dashboard_event(DashboardEvent::TransientCleared {
|
||||
seq: self.next_seq(),
|
||||
name: name.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Set of agents whose transient was cleared within the last
|
||||
/// `grace` seconds — i.e. agents the operator just acted on,
|
||||
/// whose stop the crash watcher should NOT classify as a crash.
|
||||
/// Lazily reaps entries older than `grace` so the map stays
|
||||
/// bounded by the active agent count.
|
||||
pub fn recent_transient_within(&self, grace: std::time::Duration) -> HashMap<String, TransientKind> {
|
||||
let now = std::time::Instant::now();
|
||||
let mut map = self.recent_transient.lock().unwrap();
|
||||
map.retain(|_, (_, ts)| now.duration_since(*ts) <= grace);
|
||||
map.iter().map(|(k, (kind, _))| (k.clone(), *kind)).collect()
|
||||
}
|
||||
|
||||
/// Set a transient state and return a guard that clears it on drop.
|
||||
/// Use this from any path where the surrounding future could be
|
||||
/// cancelled or panic between set and clear (HTTP handlers, spawned
|
||||
/// tasks). The guard's `Drop` runs even on task cancellation, so
|
||||
/// the dashboard's spinner can't get pinned forever.
|
||||
pub fn transient_guard(self: &Arc<Self>, name: &str, kind: TransientKind) -> TransientGuard {
|
||||
self.set_transient(name, kind);
|
||||
TransientGuard {
|
||||
coord: self.clone(),
|
||||
name: name.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transient_snapshot(&self) -> HashMap<String, TransientState> {
|
||||
self.transient.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Drop a system message into the given agent's inbox. Wakes the
|
||||
/// turn loop with a "you were just (re)started" hint — operator
|
||||
/// caused the transition, agent picks up where it left off
|
||||
/// (notes are in the bind-mounted state dir, last turn is in
|
||||
/// --continue's session). Best-effort; broker errors are logged
|
||||
/// but don't propagate.
|
||||
pub fn kick_agent(&self, name: &str, reason: &str) {
|
||||
// Sub-agents bind their state at /agents/<name>/state. The
|
||||
// manager has both /state (legacy mount) and /agents
|
||||
// bind-mounted, so /agents/<name>/state resolves there too —
|
||||
// use that uniformly so the wake message has one canonical
|
||||
// path that works everywhere.
|
||||
let body = format!(
|
||||
"{reason}\n\nYou were just (re)started by the operator. \
|
||||
If you were mid-task, check `/agents/{name}/state/` for \
|
||||
your notes and pick up where you left off. claude's \
|
||||
`--continue` session is intact, so prior context is \
|
||||
still in your window."
|
||||
);
|
||||
if let Err(e) = self.broker.send(&hive_sh4re::Message {
|
||||
from: hive_sh4re::SYSTEM_SENDER.to_owned(),
|
||||
to: name.to_owned(),
|
||||
body,
|
||||
in_reply_to: None,
|
||||
}) {
|
||||
tracing::warn!(error = ?e, %name, "kick_agent: broker.send failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a `HelperEvent` into the manager's inbox. Encoded as JSON in
|
||||
/// `Message::body`; sender = `SYSTEM_SENDER`. The manager harness
|
||||
/// recognises the sender and parses the body. Best-effort: a serde or
|
||||
/// broker error is logged but does not propagate.
|
||||
pub fn notify_manager(&self, event: &hive_sh4re::HelperEvent) {
|
||||
self.notify_agent(hive_sh4re::MANAGER_AGENT, event);
|
||||
}
|
||||
|
||||
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
|
||||
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
|
||||
/// body = JSON-encoded event). Used to route `QuestionAnswered`
|
||||
/// events back to the agent that called `ask`, `QuestionAsked`
|
||||
/// events to the target of a peer question, etc.
|
||||
pub fn notify_agent(&self, agent: &str, event: &hive_sh4re::HelperEvent) {
|
||||
self.notify_agent_from(hive_sh4re::SYSTEM_SENDER, agent, event);
|
||||
}
|
||||
|
||||
/// Same as `notify_agent` but with an explicit sender. Use this
|
||||
/// when the event originates from a known agent or the operator
|
||||
/// (e.g. `QuestionAnswered` — the answerer should be the `from`,
|
||||
/// not `system`) so the recipient's terminal shows the right name.
|
||||
pub fn notify_agent_from(&self, from: &str, agent: &str, event: &hive_sh4re::HelperEvent) {
|
||||
let body = match serde_json::to_string(event) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "failed to encode helper event");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.broker.send(&hive_sh4re::Message {
|
||||
from: from.to_owned(),
|
||||
to: agent.to_owned(),
|
||||
body,
|
||||
in_reply_to: None,
|
||||
}) {
|
||||
tracing::warn!(error = ?e, target = %agent, "failed to push helper event");
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliver `body` to every currently-registered agent except the sender,
|
||||
/// appending the standard broadcast hint. Returns a list of per-agent
|
||||
/// error strings for any that failed (empty = all ok).
|
||||
pub fn broadcast_send(&self, from: &str, body: &str) -> Vec<String> {
|
||||
const HINT: &str =
|
||||
"\n\n⚠️ _hint: this was a broadcast and may not need any action from you_";
|
||||
let broadcast_body = format!("{body}{HINT}");
|
||||
let mut errors = Vec::new();
|
||||
for agent_name in self.list_agents() {
|
||||
if agent_name == from {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = self.broker.send(&hive_sh4re::Message {
|
||||
from: from.to_owned(),
|
||||
to: agent_name.clone(),
|
||||
body: broadcast_body.clone(),
|
||||
in_reply_to: None,
|
||||
}) {
|
||||
errors.push(format!("{agent_name}: {e}"));
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
pub fn agent_dir(name: &str) -> PathBuf {
|
||||
PathBuf::from(format!("{AGENT_RUNTIME_ROOT}/{name}"))
|
||||
}
|
||||
|
||||
pub fn socket_path(name: &str) -> PathBuf {
|
||||
Self::agent_dir(name).join("mcp.sock")
|
||||
}
|
||||
|
||||
pub fn manager_dir() -> PathBuf {
|
||||
PathBuf::from(MANAGER_RUNTIME_ROOT)
|
||||
}
|
||||
|
||||
pub fn manager_socket_path() -> PathBuf {
|
||||
Self::manager_dir().join("mcp.sock")
|
||||
}
|
||||
|
||||
/// Ensure a runtime dir + (for sub-agents) per-agent socket exists. For
|
||||
/// the manager, `manager_server::start` owns the socket — just return
|
||||
/// the dir. For sub-agents this is `register_agent` (creates a fresh
|
||||
/// listener bound to `socket_path(name)`). Source directory of the
|
||||
/// `/run/hive/mcp.sock` bind that ends up in `set_nspawn_flags`.
|
||||
pub fn ensure_runtime(self: &Arc<Self>, name: &str) -> Result<PathBuf> {
|
||||
if name == crate::lifecycle::MANAGER_NAME {
|
||||
let dir = Self::manager_dir();
|
||||
std::fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("create manager dir {}", dir.display()))?;
|
||||
return Ok(dir);
|
||||
}
|
||||
self.register_agent(name)
|
||||
}
|
||||
|
||||
/// Per-agent state root (parent of `config/`, future `prompts/`, etc.).
|
||||
pub fn agent_state_root(name: &str) -> PathBuf {
|
||||
PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}"))
|
||||
}
|
||||
|
||||
/// Manager-editable proposed config repo. Bind-mounted into the manager
|
||||
/// container as `/agents/<name>/config/`.
|
||||
pub fn agent_proposed_dir(name: &str) -> PathBuf {
|
||||
Self::agent_state_root(name).join("config")
|
||||
}
|
||||
|
||||
/// Per-agent Claude credentials dir. Bind-mounted RW into the agent
|
||||
/// container at `/root/.claude` so OAuth state survives container
|
||||
/// destroy/recreate. Each agent owns its own token lineage — sharing
|
||||
/// would break on the first refresh-token rotation.
|
||||
pub fn agent_claude_dir(name: &str) -> PathBuf {
|
||||
Self::agent_state_root(name).join("claude")
|
||||
}
|
||||
|
||||
/// Per-agent durable knowledge dir. Bind-mounted RW into the agent
|
||||
/// container at `/state`. Survives destroy/recreate alongside the
|
||||
/// claude dir. Agents are told (via the system prompt) to write
|
||||
/// long-lived notes / scratch state here.
|
||||
pub fn agent_notes_dir(name: &str) -> PathBuf {
|
||||
Self::agent_state_root(name).join("state")
|
||||
}
|
||||
|
||||
/// Authoritative applied config repo. Hive-c0re-only.
|
||||
pub fn agent_applied_dir(name: &str) -> PathBuf {
|
||||
PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}"))
|
||||
}
|
||||
|
||||
/// Enumerate names that have a persistent state dir under
|
||||
/// `/var/lib/hyperhive/agents/` (i.e. config / claude creds /
|
||||
/// notes survive). Includes both currently-existing containers and
|
||||
/// destroyed-but-kept tombstones; callers filter the latter by
|
||||
/// subtracting `lifecycle::list()`.
|
||||
#[must_use]
|
||||
pub fn kept_state_names() -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(AGENT_STATE_ROOT) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<String> = rd
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
}
|
||||
229
hive-c0re/src/crash_watch.rs
Normal file
229
hive-c0re/src/crash_watch.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
//! Per-container state watcher. Polls every managed container on a
|
||||
//! fixed interval, tracks two orthogonal state-sets across ticks,
|
||||
//! and emits a `HelperEvent` to the manager on each transition:
|
||||
//!
|
||||
//! - **running**: container is up. running → stopped without an
|
||||
//! operator-initiated transient (`Stopping` / `Restarting` /
|
||||
//! `Destroying` / `Rebuilding`) → `ContainerCrash`.
|
||||
//! - **logged-in**: claude session dir is populated. ! → ✓ →
|
||||
//! `LoggedIn`; ✓ → ! → `NeedsLogin` (rare — usually only fires
|
||||
//! on a fresh spawn / purge).
|
||||
//!
|
||||
//! `NeedsUpdate` events are now fired from the apply-commit path
|
||||
//! directly rather than via rev-marker polling (issue #179 cleanup).
|
||||
//!
|
||||
//! D-Bus subscription would be lower-latency for the first axis,
|
||||
//! but polling is simpler and a 10s detection delay is fine.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::container_view::claude_has_session;
|
||||
use crate::coordinator::{Coordinator, TransientKind};
|
||||
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long an operator-initiated transient stays "recently cleared"
|
||||
/// for the purpose of suppressing crash events. Three full
|
||||
/// `POLL_INTERVAL`s gives the post-lifecycle path comfortable
|
||||
/// breathing room — the watcher will have polled at least twice
|
||||
/// inside the window even with worst-case timer skew (#425).
|
||||
const RECENT_TRANSIENT_GRACE: Duration = Duration::from_secs(30);
|
||||
|
||||
pub fn spawn(coord: Arc<Coordinator>) {
|
||||
let mut shutdown = coord.shutdown_rx();
|
||||
tokio::spawn(async move {
|
||||
let mut prev_running: HashSet<String> = HashSet::new();
|
||||
let mut prev_logged_in: HashSet<String> = HashSet::new();
|
||||
let mut prev_sub_agents: HashSet<String> = HashSet::new();
|
||||
let mut seeded = false;
|
||||
loop {
|
||||
let raw = lifecycle::list().await.unwrap_or_default();
|
||||
let mut current_running = HashSet::new();
|
||||
let mut current_logged_in = HashSet::new();
|
||||
let mut sub_agents: Vec<String> = Vec::new();
|
||||
for c in &raw {
|
||||
let logical = if c == MANAGER_NAME {
|
||||
MANAGER_NAME.to_owned()
|
||||
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
|
||||
n.to_owned()
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if logical != MANAGER_NAME {
|
||||
sub_agents.push(logical.clone());
|
||||
}
|
||||
if lifecycle::is_running(&logical).await {
|
||||
current_running.insert(logical.clone());
|
||||
}
|
||||
if logical != MANAGER_NAME
|
||||
&& claude_has_session(&Coordinator::agent_claude_dir(&logical))
|
||||
{
|
||||
current_logged_in.insert(logical.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if seeded {
|
||||
emit_crash_transitions(&coord, &prev_running, ¤t_running);
|
||||
emit_login_transitions(
|
||||
&coord,
|
||||
&prev_logged_in,
|
||||
¤t_logged_in,
|
||||
&sub_agents,
|
||||
&prev_sub_agents,
|
||||
);
|
||||
}
|
||||
// Periodic container rescan — catches state flips that
|
||||
// happen outside our mutation surface (operator runs
|
||||
// `nixos-container stop` over ssh, agent logs in via its
|
||||
// own web UI, etc.) so the dashboard converges within one
|
||||
// POLL_INTERVAL. Idempotent + cheap when nothing changed.
|
||||
coord.rescan_containers_and_emit().await;
|
||||
prev_running = current_running;
|
||||
prev_logged_in = current_logged_in;
|
||||
prev_sub_agents = sub_agents.into_iter().collect();
|
||||
seeded = true;
|
||||
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(POLL_INTERVAL) => {}
|
||||
_ = shutdown.changed() => {
|
||||
tracing::info!("crash watcher: shutdown signal received");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_crash_transitions(coord: &Coordinator, prev: &HashSet<String>, current: &HashSet<String>) {
|
||||
let transients = coord.transient_snapshot();
|
||||
// Operator actions whose RAII guard already cleared but only just;
|
||||
// suppresses the race where `lifecycle::kill` returns + drops the
|
||||
// guard between two crash-watch polls (closes #425).
|
||||
let recent = coord.recent_transient_within(RECENT_TRANSIENT_GRACE);
|
||||
for stopped in prev.difference(current) {
|
||||
let active = transients.get(stopped).map(|st| st.kind);
|
||||
let recently_cleared = recent.get(stopped).copied();
|
||||
if is_deliberate_stop(active, recently_cleared) {
|
||||
continue;
|
||||
}
|
||||
tracing::warn!(agent = %stopped, "container crash detected");
|
||||
coord.notify_manager(&hive_sh4re::HelperEvent::ContainerCrash {
|
||||
agent: stopped.clone(),
|
||||
note: Some("container stopped without an operator action".into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure classifier: did the operator stop / restart / destroy /
|
||||
/// rebuild this container, or did it crash? Splits the matcher out so
|
||||
/// it has a focused unit test (#425) without needing a Coordinator
|
||||
/// fixture. `active` is the currently-set transient (if any),
|
||||
/// `recently_cleared` is one whose RAII guard dropped within the
|
||||
/// grace window.
|
||||
fn is_deliberate_stop(
|
||||
active: Option<TransientKind>,
|
||||
recently_cleared: Option<TransientKind>,
|
||||
) -> bool {
|
||||
let is_op_kind = |kind: TransientKind| {
|
||||
matches!(
|
||||
kind,
|
||||
TransientKind::Stopping
|
||||
| TransientKind::Restarting
|
||||
| TransientKind::Destroying
|
||||
| TransientKind::Rebuilding
|
||||
)
|
||||
};
|
||||
active.is_some_and(is_op_kind) || recently_cleared.is_some_and(is_op_kind)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deliberate_when_active_transient_is_operator_kind() {
|
||||
for kind in [
|
||||
TransientKind::Stopping,
|
||||
TransientKind::Restarting,
|
||||
TransientKind::Destroying,
|
||||
TransientKind::Rebuilding,
|
||||
] {
|
||||
assert!(is_deliberate_stop(Some(kind), None), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deliberate_when_recent_transient_is_operator_kind() {
|
||||
// Race the #425 bug repros: lifecycle action completes + drops
|
||||
// the guard between two polls. recent_transient catches it.
|
||||
for kind in [
|
||||
TransientKind::Stopping,
|
||||
TransientKind::Restarting,
|
||||
TransientKind::Destroying,
|
||||
TransientKind::Rebuilding,
|
||||
] {
|
||||
assert!(is_deliberate_stop(None, Some(kind)), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_deliberate_with_no_transient_at_all() {
|
||||
// The real-crash case — fires the ContainerCrash event.
|
||||
assert!(!is_deliberate_stop(None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_deliberate_when_only_spawning_starting() {
|
||||
// Spawning/Starting are never paired with a "stopped" transition
|
||||
// — they're starts. If we see one alongside a stop, it's
|
||||
// unrelated (e.g. just-started container died), still a crash.
|
||||
for kind in [TransientKind::Spawning, TransientKind::Starting] {
|
||||
assert!(!is_deliberate_stop(Some(kind), None), "{kind:?} active");
|
||||
assert!(!is_deliberate_stop(None, Some(kind)), "{kind:?} recent");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_login_transitions(
|
||||
coord: &Coordinator,
|
||||
prev: &HashSet<String>,
|
||||
current: &HashSet<String>,
|
||||
sub_agents: &[String],
|
||||
prev_sub_agents: &HashSet<String>,
|
||||
) {
|
||||
for agent in current.difference(prev) {
|
||||
tracing::info!(%agent, "agent logged in");
|
||||
coord.notify_manager(&hive_sh4re::HelperEvent::LoggedIn {
|
||||
agent: agent.clone(),
|
||||
});
|
||||
}
|
||||
// Detect transitions into "needs login": an agent that was previously
|
||||
// logged-in goes unsigned (credentials deleted), OR a brand-new agent
|
||||
// appears without a session.
|
||||
//
|
||||
// prev_needs uses prev_sub_agents (the agent set from the last tick) so
|
||||
// that a newly-spawned agent — which does not appear in prev_sub_agents —
|
||||
// is absent from prev_needs even though it's not in prev_logged_in.
|
||||
// Without this, new agents land in both prev_needs and current_needs and
|
||||
// the set difference is empty, silently dropping the event.
|
||||
let prev_needs: HashSet<&str> = prev_sub_agents
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|n| !prev.contains(*n))
|
||||
.collect();
|
||||
let current_needs: HashSet<&str> = sub_agents
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|n| !current.contains(*n))
|
||||
.collect();
|
||||
for agent in current_needs.difference(&prev_needs) {
|
||||
tracing::info!(%agent, "agent needs login");
|
||||
coord.notify_manager(&hive_sh4re::HelperEvent::NeedsLogin {
|
||||
agent: (*agent).to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
2285
hive-c0re/src/dashboard.rs
Normal file
2285
hive-c0re/src/dashboard.rs
Normal file
File diff suppressed because it is too large
Load diff
377
hive-c0re/src/dashboard_events.rs
Normal file
377
hive-c0re/src/dashboard_events.rs
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
//! Unified dashboard event channel.
|
||||
//!
|
||||
//! Anything the browser wants to react to in near-real-time flows through
|
||||
//! `Coordinator.dashboard_events`. Each event is stamped with a monotonic
|
||||
//! per-process `seq` so the client can dedupe its buffered live traffic
|
||||
//! against snapshot/history responses (drop frames with
|
||||
//! `seq <= snapshot.seq`).
|
||||
//!
|
||||
//! Why one channel instead of one-per-domain: browsers cap concurrent
|
||||
//! SSE connections per origin (~6 in chrome) and dispatch-by-kind on the
|
||||
//! client is a one-liner. Splits get reserved for high-volume sub-streams
|
||||
//! that most consumers don't care about (none yet).
|
||||
//!
|
||||
//! Message-broker traffic (`Sent` / `Delivered`) lives on this channel
|
||||
//! too. A background forwarder task in `main.rs` subscribes to the broker
|
||||
//! and re-emits each `MessageEvent` as a `DashboardEvent::Sent` /
|
||||
//! `DashboardEvent::Delivered` with a freshly-stamped seq. Keeping the
|
||||
//! broker's intra-process channel separate avoids coupling the broker
|
||||
//! (used by `recv_blocking_batch` inside the harness loop) to dashboard
|
||||
//! presentation concerns.
|
||||
//!
|
||||
//! New mutation kinds (approval added/resolved, question added/answered,
|
||||
//! transient changed, etc.) land here as additional variants. The client
|
||||
//! dispatches by `kind` and updates the relevant section.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::container_view::ContainerView;
|
||||
use crate::dashboard::{MetaInputView, TombstoneView};
|
||||
use crate::rebuild_queue::QueueEntry;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum DashboardEvent {
|
||||
/// Broker `Sent` event mirrored onto the dashboard channel.
|
||||
/// `file_refs` carries every path-shaped token in `body` that
|
||||
/// hive-c0re verified is a regular file under the allow-listed
|
||||
/// roots (per-agent `state/` + `shared/`). The forwarder
|
||||
/// pre-validates so the dashboard doesn't need a probe
|
||||
/// endpoint — the client renders anchors only for tokens that
|
||||
/// appear in this list, everything else stays plain text.
|
||||
Sent {
|
||||
seq: u64,
|
||||
/// Broker row id. Allows the dashboard to track reply threads.
|
||||
id: i64,
|
||||
from: String,
|
||||
to: String,
|
||||
body: String,
|
||||
at: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
in_reply_to: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
file_refs: Vec<String>,
|
||||
},
|
||||
/// Broker `Delivered` event mirrored onto the dashboard channel.
|
||||
/// `file_refs` is the same shape as `Sent`.
|
||||
Delivered {
|
||||
seq: u64,
|
||||
/// Broker row id. Allows the dashboard to track reply threads.
|
||||
id: i64,
|
||||
from: String,
|
||||
to: String,
|
||||
body: String,
|
||||
at: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
in_reply_to: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
file_refs: Vec<String>,
|
||||
},
|
||||
/// A new approval landed in the pending queue. Payload carries
|
||||
/// enough to render the dashboard row without a `/api/state`
|
||||
/// refetch (`diff` is the raw unified diff text, same shape the
|
||||
/// snapshot ships).
|
||||
///
|
||||
/// The approval's own kind (`"apply_commit"` / `"spawn"`) lives on
|
||||
/// `approval_kind` rather than `kind` because the latter is taken
|
||||
/// by the serde tag identifying which `DashboardEvent` variant
|
||||
/// this is.
|
||||
ApprovalAdded {
|
||||
seq: u64,
|
||||
id: i64,
|
||||
agent: String,
|
||||
approval_kind: &'static str,
|
||||
sha_short: Option<String>,
|
||||
diff: Option<String>,
|
||||
description: Option<String>,
|
||||
},
|
||||
/// A pending approval transitioned to a terminal state
|
||||
/// (approved / denied / failed). Clients move the row out of the
|
||||
/// pending list and into history.
|
||||
ApprovalResolved {
|
||||
seq: u64,
|
||||
id: i64,
|
||||
agent: String,
|
||||
approval_kind: &'static str,
|
||||
sha_short: Option<String>,
|
||||
/// `"approved"` / `"denied"` / `"failed"`.
|
||||
status: &'static str,
|
||||
resolved_at: i64,
|
||||
note: Option<String>,
|
||||
description: Option<String>,
|
||||
},
|
||||
/// A question landed in the queue. `target = None` means
|
||||
/// operator-targeted (`Ask { to: None | Some("operator") }`);
|
||||
/// `target = Some(<agent>)` means a peer-to-peer question. Both
|
||||
/// are surfaced on the dashboard so the operator can monitor /
|
||||
/// override-answer stuck threads.
|
||||
QuestionAdded {
|
||||
seq: u64,
|
||||
id: i64,
|
||||
asker: String,
|
||||
question: String,
|
||||
options: Vec<String>,
|
||||
multi: bool,
|
||||
asked_at: i64,
|
||||
deadline_at: Option<i64>,
|
||||
target: Option<String>,
|
||||
/// Verified file-path tokens that appear in `question`.
|
||||
/// Same shape as broker `Sent`/`Delivered` events; the
|
||||
/// client linkifies only what hive-c0re vouched for.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
question_refs: Vec<String>,
|
||||
},
|
||||
/// A question was answered (operator answer, peer answer,
|
||||
/// operator override on a peer thread, or ttl watchdog
|
||||
/// `[expired]`). Clients move the row from pending to history.
|
||||
/// `cancelled = true` when the operator dismissed via the cancel
|
||||
/// button.
|
||||
QuestionResolved {
|
||||
seq: u64,
|
||||
id: i64,
|
||||
answer: String,
|
||||
answerer: String,
|
||||
answered_at: i64,
|
||||
cancelled: bool,
|
||||
target: Option<String>,
|
||||
/// Verified file-path tokens that appear in `answer`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
answer_refs: Vec<String>,
|
||||
},
|
||||
/// A lifecycle action started for an agent (spawn / start / stop
|
||||
/// / restart / rebuild / destroy). Clients render a spinner next
|
||||
/// to the row; the client computes "seconds in this state"
|
||||
/// locally from `since_unix` so a slow rebuild's elapsed time
|
||||
/// ticks without polling.
|
||||
TransientSet {
|
||||
seq: u64,
|
||||
name: String,
|
||||
/// Lifecycle kind: `"spawning"` / `"starting"` / `"stopping"` /
|
||||
/// `"restarting"` / `"rebuilding"` / `"destroying"`.
|
||||
transient_kind: &'static str,
|
||||
since_unix: i64,
|
||||
},
|
||||
/// The matching lifecycle action resolved (success or failure).
|
||||
/// Clients drop the spinner row.
|
||||
TransientCleared { seq: u64, name: String },
|
||||
/// One container row changed — new container appeared (post-spawn
|
||||
/// finalise), an existing one flipped `running` / `needs_update` /
|
||||
/// `sha`, etc. Clients upsert by `container.name`. Payload carries
|
||||
/// the full row so cold-loaded clients and event-driven clients
|
||||
/// converge on the same render.
|
||||
///
|
||||
/// Fired by `Coordinator::rescan_containers_and_emit`, which diffs
|
||||
/// a fresh `nixos-container list`–derived snapshot against the
|
||||
/// last one cached on the coordinator. Mutation sites (lifecycle
|
||||
/// endpoints, `actions::destroy` / approve, `crash_watch`'s poll loop)
|
||||
/// call the rescan after their work lands.
|
||||
ContainerStateChanged {
|
||||
seq: u64,
|
||||
container: ContainerView,
|
||||
},
|
||||
/// A container that was in the previous snapshot is gone. Clients
|
||||
/// drop the row by name. Fired alongside any
|
||||
/// `nixos-container destroy` (operator-driven or otherwise) on the
|
||||
/// next rescan.
|
||||
ContainerRemoved { seq: u64, name: String },
|
||||
/// Full snapshot of the tombstones list. Emitted on every
|
||||
/// mutation that could add / remove a tombstone: destroy
|
||||
/// (with or without purge), purge-tombstone, spawn approval
|
||||
/// (which can consume a tombstone of the same name). Snapshot
|
||||
/// shape (not diff) because the list is tiny (single-digit
|
||||
/// typical) and recomputing avoids the add/remove races a
|
||||
/// per-row event would have.
|
||||
TombstonesChanged {
|
||||
seq: u64,
|
||||
tombstones: Vec<TombstoneView>,
|
||||
},
|
||||
/// Full snapshot of `meta/flake.lock`'s root inputs. Emitted
|
||||
/// after every operation that bumps a lock: `meta-update`,
|
||||
/// `rebuild_agent` (lock bumps via two-phase staging),
|
||||
/// `update-all`. Same snapshot-shape rationale as
|
||||
/// `TombstonesChanged` — the list is small (one row per agent
|
||||
/// plus their fetched inputs).
|
||||
MetaInputsChanged {
|
||||
seq: u64,
|
||||
inputs: Vec<MetaInputView>,
|
||||
},
|
||||
/// A dashboard-triggered `meta-update` started (`running: true`) or
|
||||
/// finished (`running: false`). `post_meta_update` returns 200
|
||||
/// immediately and runs the `nix flake update` + agent-rebuild
|
||||
/// ripple in a background task — this event lets the META INPUTS
|
||||
/// panel show a disabled "updating…" state for that whole window
|
||||
/// instead of looking idle (issue #259). Emitted by
|
||||
/// `Coordinator::meta_update_guard` / `MetaUpdateGuard::drop` only
|
||||
/// when the active-run count crosses 0, so concurrent updates flip
|
||||
/// the flag exactly once.
|
||||
MetaUpdateRunning { seq: u64, running: bool },
|
||||
/// Full snapshot of the rebuild queue (`hive-c0re::rebuild_queue`)
|
||||
/// — every entry, in enqueue order, including the few most-recent
|
||||
/// terminal entries the queue retains for history. Same
|
||||
/// snapshot-shape rationale as `TombstonesChanged` /
|
||||
/// `MetaInputsChanged`: the list is small, snapshot semantics avoid
|
||||
/// the add/remove races a per-row event would have, and the
|
||||
/// dashboard's grouping (parent_id) is most naturally re-derived
|
||||
/// from the full list.
|
||||
RebuildQueueChanged {
|
||||
seq: u64,
|
||||
queue: Vec<QueueEntry>,
|
||||
},
|
||||
}
|
||||
|
||||
impl DashboardEvent {
|
||||
/// Snake-case identifier matching this variant's serde `tag`
|
||||
/// (e.g. `Sent` → `"sent"`, `ContainerStateChanged` →
|
||||
/// `"container_state_changed"`). Lets `/dashboard/stream`'s
|
||||
/// `?kinds=` filter (#408) decide whether to forward a frame
|
||||
/// without paying the JSON-serialise cost first.
|
||||
///
|
||||
/// Keep in sync with `#[serde(rename_all = "snake_case", tag =
|
||||
/// "kind")]` on `DashboardEvent` — if a new variant lands above,
|
||||
/// add it here too. `cargo test` covers this via the
|
||||
/// `kind_tag_matches_serde_kind_field` round-trip test.
|
||||
#[must_use]
|
||||
pub fn kind_tag(&self) -> &'static str {
|
||||
match self {
|
||||
DashboardEvent::Sent { .. } => "sent",
|
||||
DashboardEvent::Delivered { .. } => "delivered",
|
||||
DashboardEvent::ApprovalAdded { .. } => "approval_added",
|
||||
DashboardEvent::ApprovalResolved { .. } => "approval_resolved",
|
||||
DashboardEvent::QuestionAdded { .. } => "question_added",
|
||||
DashboardEvent::QuestionResolved { .. } => "question_resolved",
|
||||
DashboardEvent::TransientSet { .. } => "transient_set",
|
||||
DashboardEvent::TransientCleared { .. } => "transient_cleared",
|
||||
DashboardEvent::ContainerStateChanged { .. } => "container_state_changed",
|
||||
DashboardEvent::ContainerRemoved { .. } => "container_removed",
|
||||
DashboardEvent::TombstonesChanged { .. } => "tombstones_changed",
|
||||
DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed",
|
||||
DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running",
|
||||
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Round-trip representative variants through serde and confirm
|
||||
/// the `kind` JSON field matches `kind_tag()`. The exhaustive
|
||||
/// `match` in `kind_tag` already provides compile-time variant
|
||||
/// coverage — this test is the value-side guard against
|
||||
/// typos in the snake_case strings vs serde's `rename_all`
|
||||
/// output. `ContainerStateChanged` is omitted from the sample
|
||||
/// list only because `ContainerView` has no `Default` impl and
|
||||
/// constructing one inline here is more boilerplate than the
|
||||
/// test is worth; the variant is still covered by the
|
||||
/// `kind_tag` match arm.
|
||||
#[test]
|
||||
fn kind_tag_matches_serde_kind_field() {
|
||||
let samples: Vec<DashboardEvent> = vec![
|
||||
DashboardEvent::Sent {
|
||||
seq: 1,
|
||||
id: 1,
|
||||
from: "a".into(),
|
||||
to: "b".into(),
|
||||
body: String::new(),
|
||||
at: 0,
|
||||
in_reply_to: None,
|
||||
file_refs: Vec::new(),
|
||||
},
|
||||
DashboardEvent::Delivered {
|
||||
seq: 1,
|
||||
id: 1,
|
||||
from: "a".into(),
|
||||
to: "b".into(),
|
||||
body: String::new(),
|
||||
at: 0,
|
||||
in_reply_to: None,
|
||||
file_refs: Vec::new(),
|
||||
},
|
||||
DashboardEvent::ApprovalAdded {
|
||||
seq: 1,
|
||||
id: 1,
|
||||
agent: "x".into(),
|
||||
approval_kind: "apply_commit",
|
||||
sha_short: None,
|
||||
diff: None,
|
||||
description: None,
|
||||
},
|
||||
DashboardEvent::ApprovalResolved {
|
||||
seq: 1,
|
||||
id: 1,
|
||||
agent: "x".into(),
|
||||
approval_kind: "apply_commit",
|
||||
sha_short: None,
|
||||
status: "approved",
|
||||
resolved_at: 0,
|
||||
note: None,
|
||||
description: None,
|
||||
},
|
||||
DashboardEvent::QuestionAdded {
|
||||
seq: 1,
|
||||
id: 1,
|
||||
asker: "a".into(),
|
||||
question: String::new(),
|
||||
options: Vec::new(),
|
||||
multi: false,
|
||||
asked_at: 0,
|
||||
deadline_at: None,
|
||||
target: None,
|
||||
question_refs: Vec::new(),
|
||||
},
|
||||
DashboardEvent::QuestionResolved {
|
||||
seq: 1,
|
||||
id: 1,
|
||||
answer: String::new(),
|
||||
answerer: "a".into(),
|
||||
answered_at: 0,
|
||||
cancelled: false,
|
||||
target: None,
|
||||
answer_refs: Vec::new(),
|
||||
},
|
||||
DashboardEvent::TransientSet {
|
||||
seq: 1,
|
||||
name: "x".into(),
|
||||
transient_kind: "rebuilding",
|
||||
since_unix: 0,
|
||||
},
|
||||
DashboardEvent::TransientCleared {
|
||||
seq: 1,
|
||||
name: "x".into(),
|
||||
},
|
||||
DashboardEvent::ContainerRemoved {
|
||||
seq: 1,
|
||||
name: "x".into(),
|
||||
},
|
||||
DashboardEvent::TombstonesChanged {
|
||||
seq: 1,
|
||||
tombstones: Vec::new(),
|
||||
},
|
||||
DashboardEvent::MetaInputsChanged {
|
||||
seq: 1,
|
||||
inputs: Vec::new(),
|
||||
},
|
||||
DashboardEvent::MetaUpdateRunning {
|
||||
seq: 1,
|
||||
running: false,
|
||||
},
|
||||
DashboardEvent::RebuildQueueChanged {
|
||||
seq: 1,
|
||||
queue: Vec::new(),
|
||||
},
|
||||
];
|
||||
for ev in samples {
|
||||
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
|
||||
let serde_kind = v
|
||||
.get("kind")
|
||||
.and_then(|k| k.as_str())
|
||||
.expect("kind field present");
|
||||
assert_eq!(
|
||||
ev.kind_tag(),
|
||||
serde_kind,
|
||||
"kind_tag() drift on {ev:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
hive-c0re/src/events_vacuum.rs
Normal file
66
hive-c0re/src/events_vacuum.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
//! Host-side vacuum of every per-agent events.sqlite. The harness
|
||||
//! writes to `/state/hyperhive-events.sqlite` (bind-mounted from
|
||||
//! `/var/lib/hyperhive/agents/<name>/state/`); we open the same file
|
||||
//! from the host every hour and delete rows older than `KEEP_SECS`.
|
||||
//! Age-only — no row cap — so a chatty turn doesn't lose history
|
||||
//! sooner than a quiet one; disk pressure on a sustained burst is
|
||||
//! a cheaper problem than a missing event when the operator is
|
||||
//! debugging a regression. Keeping retention on the host means
|
||||
//! agents don't need any cleanup wiring of their own, and a
|
||||
//! misbehaving harness can't disable its own vacuum.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{Connection, Result, params};
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
const VACUUM_INTERVAL: Duration = Duration::from_secs(3600);
|
||||
const KEEP_SECS: i64 = 7 * 24 * 3600;
|
||||
|
||||
/// Background loop: sweep every existing agent state dir hourly, run
|
||||
/// the vacuum SQL against its events.sqlite if present. Errors are
|
||||
/// logged but don't tear the loop down.
|
||||
pub fn spawn(coord: &Arc<Coordinator>) {
|
||||
let mut shutdown = coord.shutdown_rx();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
sweep_once();
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(VACUUM_INTERVAL) => {}
|
||||
_ = shutdown.changed() => {
|
||||
tracing::info!("events vacuum: shutdown signal received");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn sweep_once() {
|
||||
for name in Coordinator::kept_state_names() {
|
||||
let path = Coordinator::agent_notes_dir(&name).join("hyperhive-events.sqlite");
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
match vacuum_file(&path) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!(agent = %name, removed = n, "events vacuum"),
|
||||
Err(e) => tracing::warn!(agent = %name, error = ?e, "events vacuum failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn vacuum_file(path: &Path) -> Result<u64> {
|
||||
let conn = Connection::open(path)?;
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0);
|
||||
let cutoff = now - KEEP_SECS;
|
||||
let removed = conn.execute("DELETE FROM events WHERE ts < ?1", params![cutoff])?;
|
||||
Ok(u64::try_from(removed).unwrap_or(0))
|
||||
}
|
||||
431
hive-c0re/src/flake_check.rs
Normal file
431
hive-c0re/src/flake_check.rs
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
//! Pre-apply validation for agent `flake.lock` files (closes part of #317).
|
||||
//!
|
||||
//! Every `request_apply_commit` lands a `proposal/<id>` tag in the
|
||||
//! agent's applied repo before the operator sees the approval. We
|
||||
//! parse `flake.lock` from that tag's tree and reject the request if
|
||||
//! two or more nodes share an identical `original` field — that
|
||||
//! signals a missing `inputs.<X>.inputs.nixpkgs.follows = "nixpkgs"`
|
||||
//! directive in `flake.nix` and would inflate meta's lock with
|
||||
//! duplicates after deploy.
|
||||
//!
|
||||
//! Per mara's scope note on #317 (comment 4189): the check runs on
|
||||
//! the agent repo, not meta, and catches *new* violations only.
|
||||
//! Existing agents whose lock already has duplicates are out of
|
||||
//! scope here and get a coordinated config-change pass via the
|
||||
//! manager instead.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::lifecycle::git_command;
|
||||
|
||||
/// One group of `flake.lock` nodes that all share the same canonical
|
||||
/// `original` reference. Surfaced in the rejection message so the
|
||||
/// operator (and the manager that submitted the apply) can see
|
||||
/// exactly which input pair needs a `follows` directive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DuplicateGroup {
|
||||
/// One of the original `Value`s from the lock — used for
|
||||
/// pretty-printing in the error message. Canonical equivalence
|
||||
/// is enforced by the `BTreeMap` key in `duplicate_groups`, so
|
||||
/// we don't need to keep the canonicalised form on the struct.
|
||||
pub original: Value,
|
||||
/// Names of the flake.lock nodes that share this `original`,
|
||||
/// sorted for stable error output.
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read `flake.lock` from `<tag>:flake.lock` in `repo`. Returns
|
||||
/// `Ok(None)` when the file isn't tracked in that tag (no inputs ⇒
|
||||
/// nothing to dedup); `Err` only on real git plumbing failures.
|
||||
async fn read_lock_at_tag(repo: &Path, tag: &str) -> Result<Option<String>> {
|
||||
let spec = format!("{tag}:flake.lock");
|
||||
let out = git_command()
|
||||
.current_dir(repo)
|
||||
.args(["show", &spec])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git show {spec} in {}", repo.display()))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
// git uses two different messages for "path not in tree"
|
||||
// depending on whether the path also collides with an on-disk
|
||||
// file. Both translate to "no flake.lock in this commit" —
|
||||
// a legitimate, dedup-clean state for an agent with empty
|
||||
// `inputs = { }`. Any other git failure (permission denied,
|
||||
// ref-not-found, etc.) propagates as a hard error rather than
|
||||
// being silently swallowed.
|
||||
if stderr.contains("does not exist")
|
||||
|| stderr.contains("exists on disk, but not in")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
anyhow::bail!("git show {spec} failed: {}", stderr.trim());
|
||||
}
|
||||
Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()))
|
||||
}
|
||||
|
||||
/// Recursively serialise `v` with object keys sorted, so two
|
||||
/// JSON values that differ only in key insertion order produce the
|
||||
/// same string. `serde_json::Value` preserves `IndexMap` order by
|
||||
/// default, which is fine for parsing but breaks our group-by-key
|
||||
/// idea — hence this hand-rolled canonicaliser.
|
||||
fn canonical_json(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
let mut s = String::from("{");
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&serde_json::to_string(k).unwrap_or_default());
|
||||
s.push(':');
|
||||
s.push_str(&canonical_json(&map[*k]));
|
||||
}
|
||||
s.push('}');
|
||||
s
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
let mut s = String::from("[");
|
||||
for (i, x) in arr.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&canonical_json(x));
|
||||
}
|
||||
s.push(']');
|
||||
s
|
||||
}
|
||||
_ => serde_json::to_string(v).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `raw` (a `flake.lock` JSON document) and return every group
|
||||
/// of nodes whose `original` field is identical. Nodes without an
|
||||
/// `original` (the synthetic `root`, or anomalous entries) are
|
||||
/// skipped. Groups with only one member are filtered out — only true
|
||||
/// duplicates surface.
|
||||
///
|
||||
/// Pure function, no I/O — covered by the unit tests below.
|
||||
pub fn duplicate_groups(raw: &str) -> Result<Vec<DuplicateGroup>> {
|
||||
let json: Value = serde_json::from_str(raw).context("parse flake.lock")?;
|
||||
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut groups: BTreeMap<String, DuplicateGroup> = BTreeMap::new();
|
||||
for (name, node) in nodes {
|
||||
let Some(original) = node.get("original") else {
|
||||
continue;
|
||||
};
|
||||
let key = canonical_json(original);
|
||||
let entry = groups.entry(key).or_insert_with(|| DuplicateGroup {
|
||||
original: original.clone(),
|
||||
keys: Vec::new(),
|
||||
});
|
||||
entry.keys.push(name.clone());
|
||||
}
|
||||
let mut dups: Vec<DuplicateGroup> = groups
|
||||
.into_values()
|
||||
.filter(|g| g.keys.len() > 1)
|
||||
.collect();
|
||||
for g in &mut dups {
|
||||
g.keys.sort();
|
||||
}
|
||||
Ok(dups)
|
||||
}
|
||||
|
||||
/// Re-derive the agent's `flake.lock` from its `flake.nix` (in a
|
||||
/// throw-away worktree at the proposal tag) and reject the apply when
|
||||
/// the result differs from what's committed — that means the manager
|
||||
/// edited `flake.nix` but didn't commit the regenerated lock, so the
|
||||
/// shipped state lies about what nix will actually fetch.
|
||||
///
|
||||
/// Plain `nix flake lock` (no `--update-input` flags) only fills in
|
||||
/// MISSING entries; it never refreshes existing ones. So a lock that
|
||||
/// matches its `flake.nix` round-trips to a no-op, and any diff is a
|
||||
/// real "stale lock" signal.
|
||||
///
|
||||
/// Materialises the proposal tag into a temp worktree under
|
||||
/// `std::env::temp_dir()` to avoid touching `applied/<n>/main` while
|
||||
/// the check runs. Cleanup is unconditional via the inner-fn pattern.
|
||||
///
|
||||
/// Returns `Ok(())` when in sync (or there's no `flake.nix` at all);
|
||||
/// `Err` with a human-readable message on stale lock or nix tooling
|
||||
/// failure.
|
||||
pub async fn check_lock_in_sync(repo: &Path, tag: &str, approval_id: i64) -> Result<()> {
|
||||
let suffix = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let tmp_dir = std::env::temp_dir().join(format!("hive-flake-check-{approval_id}-{suffix}"));
|
||||
|
||||
// Detached worktree at the proposal tag — gives us a clean, mutable
|
||||
// copy of the agent's tree without disturbing whatever's currently
|
||||
// checked out on `applied/<n>/main`.
|
||||
let out = git_command()
|
||||
.current_dir(repo)
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
"--detach",
|
||||
&tmp_dir.to_string_lossy(),
|
||||
tag,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git worktree add {} {tag}", tmp_dir.display()))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git worktree add failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let result = lock_in_sync_inner(&tmp_dir).await;
|
||||
|
||||
// Best-effort cleanup. `git worktree remove --force` handles the
|
||||
// common case; `remove_dir_all` mops up if git decided the worktree
|
||||
// is half-gone (or if the inner work bailed before nix touched the
|
||||
// tree). Failures here are logged, not propagated — the check's
|
||||
// result is what matters.
|
||||
if let Err(e) = remove_worktree(repo, &tmp_dir).await {
|
||||
tracing::warn!(
|
||||
worktree = %tmp_dir.display(),
|
||||
error = %format!("{e:#}"),
|
||||
"flake_check: temp worktree cleanup failed"
|
||||
);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn lock_in_sync_inner(worktree: &Path) -> Result<()> {
|
||||
// No `flake.nix` means there's nothing for nix to lock — skip the
|
||||
// check cleanly (the dedup check will likewise no-op).
|
||||
if !worktree.join("flake.nix").exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let committed = tokio::fs::read_to_string(worktree.join("flake.lock"))
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// `--extra-experimental-features` mirrors `meta::nix` for hosts
|
||||
// that haven't already enabled flakes in `nix.conf`. Plain
|
||||
// `nix flake lock` (no `--update-input`) fills missing entries but
|
||||
// never refreshes existing ones — exactly the semantics we want.
|
||||
let out = Command::new("nix")
|
||||
.current_dir(worktree)
|
||||
.args([
|
||||
"--extra-experimental-features",
|
||||
"nix-command flakes",
|
||||
"flake",
|
||||
"lock",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("nix flake lock in {}", worktree.display()))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"nix flake lock failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let regenerated = tokio::fs::read_to_string(worktree.join("flake.lock"))
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// An agent that declares inputs in flake.nix but ships no
|
||||
// flake.lock at all hits this branch (committed = None,
|
||||
// regenerated = Some(...)). That's a deliberate reject: every
|
||||
// agent with inputs MUST commit its lock, otherwise meta's
|
||||
// dedup pass has nothing to introspect and the broken state
|
||||
// leaks downstream. Treated identically to a stale lock.
|
||||
if committed.as_deref() != regenerated.as_deref() {
|
||||
anyhow::bail!(
|
||||
"flake.lock is out of sync with flake.nix — `nix flake lock` produces a different lock. \
|
||||
Run `nix flake lock` in your agent config, commit the result, and re-submit request_apply_commit."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_worktree(repo: &Path, worktree: &Path) -> Result<()> {
|
||||
let out = git_command()
|
||||
.current_dir(repo)
|
||||
.args([
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
&worktree.to_string_lossy(),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git worktree remove {}", worktree.display()))?;
|
||||
if !out.status.success() {
|
||||
// `git worktree remove` already errored — still try the raw
|
||||
// rmdir so we don't leak the dir on disk. Surface the original
|
||||
// git stderr for context.
|
||||
let _ = tokio::fs::remove_dir_all(worktree).await;
|
||||
anyhow::bail!(
|
||||
"git worktree remove failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
// git removed the worktree's metadata but the dir itself may
|
||||
// linger on stripped-down git versions — best-effort clean.
|
||||
let _ = tokio::fs::remove_dir_all(worktree).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the dedup check against the agent's freshly-applied tree.
|
||||
///
|
||||
/// `Ok(())` means either the commit doesn't carry a `flake.lock` (no
|
||||
/// inputs declared) or every node has a unique `original`. `Err`
|
||||
/// carries a multi-line message listing every duplicate group with
|
||||
/// the offending node names, suitable for surfacing on the failed
|
||||
/// approval row.
|
||||
pub async fn check_no_duplicate_inputs(repo: &Path, tag: &str) -> Result<()> {
|
||||
let Some(raw) = read_lock_at_tag(repo, tag).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let dups = duplicate_groups(&raw)?;
|
||||
if dups.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut msg = String::from(
|
||||
"flake.lock has duplicate flake inputs — add a `follows` directive in flake.nix to collapse them:\n",
|
||||
);
|
||||
for g in &dups {
|
||||
let original = serde_json::to_string(&g.original).unwrap_or_else(|_| "?".into());
|
||||
let _ = writeln!(msg, " - {original} → nodes [{}]", g.keys.join(", "));
|
||||
}
|
||||
anyhow::bail!("{}", msg.trim_end());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CLEAN_LOCK: &str = r#"{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {"rev": "aaa"},
|
||||
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {"rev": "bbb"},
|
||||
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixpkgs-unstable", "type": "github"}
|
||||
},
|
||||
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}"#;
|
||||
|
||||
const DUPLICATE_LOCK: &str = r#"{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {"rev": "aaa"},
|
||||
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {"rev": "ccc"},
|
||||
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {"rev": "ddd"},
|
||||
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"locked": {"rev": "eee"},
|
||||
"original": {"owner": "numtide", "repo": "treefmt-nix", "type": "github"}
|
||||
},
|
||||
"treefmt-nix_2": {
|
||||
"locked": {"rev": "fff"},
|
||||
"original": {"type": "github", "owner": "numtide", "repo": "treefmt-nix"}
|
||||
},
|
||||
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn clean_lock_has_no_duplicates() {
|
||||
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
|
||||
assert!(dups.is_empty(), "expected no dups, got {dups:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_lock_reports_groups() {
|
||||
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
|
||||
assert_eq!(dups.len(), 2, "expected nixpkgs + treefmt-nix groups");
|
||||
|
||||
// Order is BTreeMap-stable: sorted by canonical_json key. The
|
||||
// numtide treefmt-nix key sorts before the NixOS nixpkgs one
|
||||
// because `numtide` < `NixOS` lexicographically (case-sensitive,
|
||||
// capitals come first... wait — capital N is 0x4e, lowercase n
|
||||
// is 0x6e, so capitals come first). So nixpkgs group sorts
|
||||
// first. Verify by content instead of position to avoid coupling
|
||||
// to that subtlety.
|
||||
let nixpkgs_group = dups
|
||||
.iter()
|
||||
.find(|g| g.original.get("ref").is_some())
|
||||
.expect("nixpkgs group present");
|
||||
assert_eq!(
|
||||
nixpkgs_group.keys,
|
||||
vec!["nixpkgs", "nixpkgs_2", "nixpkgs_3"]
|
||||
);
|
||||
|
||||
let treefmt_group = dups
|
||||
.iter()
|
||||
.find(|g| g.original.get("ref").is_none())
|
||||
.expect("treefmt-nix group present");
|
||||
assert_eq!(treefmt_group.keys, vec!["treefmt-nix", "treefmt-nix_2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_order_in_original_does_not_matter() {
|
||||
// The treefmt-nix and treefmt-nix_2 entries above use different
|
||||
// key orderings for `original` ({owner,repo,type} vs
|
||||
// {type,owner,repo}); duplicate_groups should still merge them.
|
||||
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
|
||||
let treefmt = dups
|
||||
.iter()
|
||||
.find(|g| g.keys.iter().any(|k| k == "treefmt-nix"))
|
||||
.expect("treefmt-nix group");
|
||||
assert!(treefmt.keys.contains(&"treefmt-nix_2".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodes_without_original_are_ignored() {
|
||||
// The synthetic `root` node has no `original` and must not be
|
||||
// grouped against anything.
|
||||
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
|
||||
assert!(dups.iter().all(|g| !g.keys.iter().any(|k| k == "root")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_nodes_object_is_not_an_error() {
|
||||
// A lock file that's syntactically JSON but lacks `nodes` (e.g.
|
||||
// a partial test fixture) should fail open — no dups reported.
|
||||
let raw = r#"{"root": "root", "version": 7}"#;
|
||||
let dups = duplicate_groups(raw).expect("parse");
|
||||
assert!(dups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_is_a_hard_error() {
|
||||
let err = duplicate_groups("not-json-at-all").unwrap_err();
|
||||
assert!(format!("{err:#}").contains("parse flake.lock"));
|
||||
}
|
||||
}
|
||||
691
hive-c0re/src/forge.rs
Normal file
691
hive-c0re/src/forge.rs
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
//! Optional Forgejo wiring. When the `hive-forge` nixos-container is
|
||||
//! present and running, hive-c0re ensures every agent (and the
|
||||
//! manager) has a corresponding forgejo user with an API token
|
||||
//! written to `<agent-state>/forge-token` — visible inside the
|
||||
//! container as `/state/forge-token`. Idempotent: skips creation
|
||||
//! when the user already exists, skips token issuance when the file
|
||||
//! is already there.
|
||||
//!
|
||||
//! It also mirrors each agent's hive-c0re-owned *applied* config repo
|
||||
//! into the private `agent-configs` org (`push_config`), so every
|
||||
//! deploy / approval tag core plants is visible on the forge. Each
|
||||
//! agent is a read-only collaborator on `core/meta` (the meta flake)
|
||||
//! so they can fetch their deployment context; the `agent-configs`
|
||||
//! repos remain core-only.
|
||||
//!
|
||||
//! No-op when `hive-forge` isn't enabled (detected via
|
||||
//! `nixos-container list`), so operators who don't run the bundled
|
||||
//! forge pay nothing.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use reqwest::StatusCode;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
const FORGE_CONTAINER: &str = "hive-forge";
|
||||
const FORGE_HTTP: &str = "http://localhost:3000";
|
||||
const TOKEN_NAME_PREFIX: &str = "hyperhive";
|
||||
/// Where the host-side `core` admin token lives. Used by hive-c0re
|
||||
/// itself to push the meta repo + drive admin API calls (org
|
||||
/// creation, future webhook setup, etc.). Root-only.
|
||||
const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token";
|
||||
/// Marker that records whether `ensure_core_avatar` has successfully
|
||||
/// uploaded the hyperhive logo as `core`'s avatar (issue #320). One-shot:
|
||||
/// the upload runs once, the marker is written, subsequent startups skip
|
||||
/// the call. Delete to force re-upload.
|
||||
const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set";
|
||||
/// Sibling marker for the `agent-configs` org avatar (#424). Same one-
|
||||
/// shot semantics — delete to force the upload to re-run.
|
||||
const CONFIG_ORG_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-agent-configs-avatar-set";
|
||||
/// Hyperhive logo bytes, baked into the daemon. Uploaded once via the
|
||||
/// admin avatar API so the `core` Forgejo user shows the project mark
|
||||
/// next to commits in `agent-configs/*`, `core/meta`, etc. instead of
|
||||
/// the default hash identicon.
|
||||
const CORE_AVATAR_PNG: &[u8] = include_bytes!("../../branding/hyperhive.png");
|
||||
/// `agent-configs` org logo bytes (#424). Sibling visual to the main
|
||||
/// hyperhive mark — same dark base + outer ring + corner brackets,
|
||||
/// with a stacked-config-files glyph in the centre so the operator
|
||||
/// can distinguish the agent-configs namespace from the main
|
||||
/// `hyperhive` org at a glance. Source-of-truth is
|
||||
/// `branding/agent-configs.svg`; `hive-c0re/build.rs` renders it
|
||||
/// into `$OUT_DIR/agent-configs.png` at compile time via
|
||||
/// `rsvg-convert` so the raster never gets checked into git.
|
||||
const CONFIG_ORG_AVATAR_PNG: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/agent-configs.png"));
|
||||
/// Forgejo org grouping every agent's applied config repo. Core is a
|
||||
/// site admin and reads + writes every repo here; agents are NOT
|
||||
/// members and the repos are private, so no agent — not even the one
|
||||
/// a repo describes — can reach a config repo through the forge. The
|
||||
/// applied repos stay hive-c0re-owned on disk; this org is just a
|
||||
/// mirror target core pushes to.
|
||||
const CONFIG_ORG: &str = "agent-configs";
|
||||
/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at
|
||||
/// `core/meta` (the `core` user's own namespace — no org needed).
|
||||
const SEEDED_ORGS: &[&str] = &[CONFIG_ORG];
|
||||
/// Forgejo scopes the agent's token gets. Broad-but-not-admin: every
|
||||
/// repo / PR / issue thing an agent needs day-to-day, no admin
|
||||
/// surface.
|
||||
/// - `write:repository` — create, clone, push, delete repos in the
|
||||
/// user's own namespace; merge PRs.
|
||||
/// - `write:issue` — open / comment / review issues *and* pull
|
||||
/// requests (forgejo namespaces PR conversation under issues).
|
||||
/// - `write:user` — edit own profile, create repos under own user.
|
||||
/// - `write:organization` — create + manage orgs (lets agents share
|
||||
/// a forge namespace).
|
||||
/// - `read:user` — token-owner endpoint clients call to introspect.
|
||||
/// - `write:misc` — hooks, attachments, the rest of the long tail.
|
||||
/// - `read:notification` — required by `forge_notify` to poll
|
||||
/// `GET /notifications` for unread PR/review events.
|
||||
/// - `write:notification` — required by `forge_notify` to mark
|
||||
/// notifications as read via `PATCH /notifications/threads/{id}`.
|
||||
const TOKEN_SCOPES: &str =
|
||||
"read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
||||
|
||||
/// Token file inside the agent's bind-mounted state dir (visible as
|
||||
/// `/state/forge-token` from inside the container).
|
||||
fn token_path(name: &str) -> PathBuf {
|
||||
Coordinator::agent_notes_dir(name).join("forge-token")
|
||||
}
|
||||
|
||||
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
|
||||
/// `nixos-container list` is just a directory scan in /etc.
|
||||
pub async fn is_present() -> bool {
|
||||
let Ok(out) = Command::new("nixos-container").arg("list").output().await else {
|
||||
return false;
|
||||
};
|
||||
if !out.status.success() {
|
||||
return false;
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.any(|l| l.trim() == FORGE_CONTAINER)
|
||||
}
|
||||
|
||||
/// Run `forgejo admin <args>` inside the hive-forge container as the
|
||||
/// forgejo user (the only uid with write access to the state dir).
|
||||
/// Returns stdout on success; bails with stderr context on failure.
|
||||
async fn forge_admin(args: &[&str]) -> Result<String> {
|
||||
let mut cmd = Command::new("nixos-container");
|
||||
// `runuser` (util-linux, always present in a NixOS container)
|
||||
// beats `sudo` here — sudo isn't installed unless `security.sudo`
|
||||
// is enabled, and we don't want to depend on that.
|
||||
//
|
||||
// `--work-path` is mandatory: without it, the admin CLI defaults
|
||||
// WorkPath to `dirname(executable)` (a RO nix-store path), then
|
||||
// looks for `<WorkPath>/custom/conf/app.ini` which doesn't
|
||||
// exist, falls back to defaults, and F3 init tries to mkdir
|
||||
// under the nix store and fatals. The systemd unit sets
|
||||
// WORK_PATH for the daemon; we mirror it here for the CLI.
|
||||
cmd.args([
|
||||
"run",
|
||||
FORGE_CONTAINER,
|
||||
"--",
|
||||
"runuser",
|
||||
"-u",
|
||||
"forgejo",
|
||||
"--",
|
||||
"forgejo",
|
||||
"--work-path",
|
||||
"/var/lib/forgejo",
|
||||
"admin",
|
||||
]);
|
||||
cmd.args(args);
|
||||
let out = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("invoke nixos-container run hive-forge -- forgejo admin")?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"forgejo admin {} failed ({}): {}",
|
||||
args.join(" "),
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim(),
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Pull the access token out of forgejo's success message. Format
|
||||
/// has shifted across versions (table form vs. "Access token was
|
||||
/// successfully created: <hex>"), so just hunt the output for the
|
||||
/// first long hex-looking word.
|
||||
fn extract_token(output: &str) -> Option<String> {
|
||||
output
|
||||
.split(|c: char| c.is_whitespace() || c == ',' || c == ':')
|
||||
.find(|w| w.len() >= 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Canonical email address for a hive agent's Forgejo account.
|
||||
/// Must match the `user.email` set by `meta::render_flake` so commits
|
||||
/// by the agent link back to their Forgejo profile page.
|
||||
fn agent_email(name: &str) -> String {
|
||||
format!("{name}@hyperhive")
|
||||
}
|
||||
|
||||
/// Thin Forgejo REST helper. Sends `method` to `url` with a JSON body
|
||||
/// and `Authorization: token <token>`, returns the HTTP status code.
|
||||
/// All Forgejo API calls that don't shell out to `forgejo admin` go
|
||||
/// through here — one place for auth header, content-type, error
|
||||
/// propagation, and the shared reqwest Client.
|
||||
async fn forge_http(
|
||||
method: reqwest::Method,
|
||||
url: &str,
|
||||
token: &str,
|
||||
body: &str,
|
||||
) -> Result<StatusCode> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.request(method, url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_owned())
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("forge HTTP request to {url}"))?;
|
||||
Ok(resp.status())
|
||||
}
|
||||
|
||||
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
|
||||
/// returns a "user already exists" error which we treat as success.
|
||||
/// `admin` adds `--admin` (site admin) — used for the bootstrap
|
||||
/// `core` user that drives the API.
|
||||
async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
|
||||
let mut args = vec![
|
||||
"user",
|
||||
"create",
|
||||
"--username",
|
||||
name,
|
||||
"--email",
|
||||
];
|
||||
let email = agent_email(name);
|
||||
args.push(&email);
|
||||
args.extend(["--random-password", "--must-change-password=false"]);
|
||||
if admin {
|
||||
args.push("--admin");
|
||||
}
|
||||
let result = forge_admin(&args).await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
tracing::info!(%name, "forge: created user");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Forgejo's "already exists" error wording varies; just
|
||||
// try the next step and let token issuance surface a
|
||||
// real failure if the user truly isn't there.
|
||||
let msg = format!("{e:#}");
|
||||
if msg.contains("already exists") || msg.contains("user already") {
|
||||
tracing::debug!(%name, "forge: user already exists");
|
||||
Ok(())
|
||||
} else {
|
||||
tracing::warn!(%name, error = %msg, "forge: user create unclear; trying token anyway");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotently align the Forgejo account email to `agent_email(name)`.
|
||||
/// Existing agents were created with `{name}@hive.local`; this corrects
|
||||
/// that so git commits (which use `{name}@hyperhive`) link to profiles.
|
||||
/// Best-effort: failures are warned, not propagated.
|
||||
async fn ensure_user_email(name: &str) {
|
||||
let email = agent_email(name);
|
||||
match forge_admin(&["user", "edit", "--username", name, "--email", &email]).await {
|
||||
Ok(_) => tracing::debug!(%name, %email, "forge: user email aligned"),
|
||||
Err(e) => tracing::warn!(%name, error = %e, "forge: could not align user email"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a fresh access token for `name` and persist it to
|
||||
/// `<state>/forge-token` (0600). Token name is suffixed with a
|
||||
/// monotonic clock so re-issuing doesn't collide with an existing
|
||||
/// token of the same name in the DB.
|
||||
async fn mint_and_persist_token(name: &str, path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let token_name = format!(
|
||||
"{TOKEN_NAME_PREFIX}-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
let stdout = forge_admin(&[
|
||||
"user",
|
||||
"generate-access-token",
|
||||
"--username",
|
||||
name,
|
||||
"--token-name",
|
||||
&token_name,
|
||||
"--scopes",
|
||||
TOKEN_SCOPES,
|
||||
])
|
||||
.await?;
|
||||
let token = extract_token(&stdout)
|
||||
.with_context(|| format!("parse token from forgejo output: {stdout:?}"))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(path, format!("{token}\n"))
|
||||
.with_context(|| format!("write token to {}", path.display()))?;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
tracing::info!(%name, path = %path.display(), %token_name, "forge: persisted access token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure `name` has a forgejo user + token file. Always re-mints the
|
||||
/// token so the on-disk file always reflects the current `TOKEN_SCOPES`.
|
||||
/// Safe to call on every spawn and on every hive-c0re startup.
|
||||
pub async fn ensure_user_for(name: &str) -> Result<()> {
|
||||
if !is_present().await {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_user_exists(name, false).await?;
|
||||
ensure_user_email(name).await;
|
||||
mint_and_persist_token(name, &token_path(name)).await
|
||||
}
|
||||
|
||||
/// Set `core`'s Forgejo avatar to the hyperhive logo once, then
|
||||
/// remember it so subsequent startups don't re-upload (issue #320).
|
||||
/// Best-effort — any non-2xx is logged at the caller; the project
|
||||
/// runs fine with the default hash identicon.
|
||||
async fn ensure_core_avatar(token: &str) -> Result<()> {
|
||||
let marker = std::path::Path::new(CORE_AVATAR_MARKER);
|
||||
if marker.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let body = format!(
|
||||
r#"{{"image":"{}"}}"#,
|
||||
base64::engine::general_purpose::STANDARD.encode(CORE_AVATAR_PNG),
|
||||
);
|
||||
let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar");
|
||||
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("set core avatar: HTTP {status}");
|
||||
}
|
||||
if let Some(parent) = marker.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(marker, "").ok();
|
||||
tracing::info!("forge: set core user avatar to hyperhive logo");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the `agent-configs` org's Forgejo avatar to the
|
||||
/// configs-stack glyph once (#424). Sibling to `ensure_core_avatar`:
|
||||
/// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar
|
||||
/// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG
|
||||
/// JSON body — same shape as the admin user endpoint above.
|
||||
async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
||||
let marker = std::path::Path::new(CONFIG_ORG_AVATAR_MARKER);
|
||||
if marker.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let body = format!(
|
||||
r#"{{"image":"{}"}}"#,
|
||||
base64::engine::general_purpose::STANDARD.encode(CONFIG_ORG_AVATAR_PNG),
|
||||
);
|
||||
let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar");
|
||||
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}");
|
||||
}
|
||||
if let Some(parent) = marker.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(marker, "").ok();
|
||||
tracing::info!(org = CONFIG_ORG, "forge: set org avatar to configs-stack logo");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the bootstrap `core` admin user + a token at
|
||||
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
|
||||
/// API calls (org creation now, meta-repo push later). Returns the
|
||||
/// token. Idempotent: skips creation when user exists, skips token
|
||||
/// when the file is present.
|
||||
async fn ensure_core_user_and_token() -> Result<String> {
|
||||
let path = std::path::Path::new(CORE_TOKEN_PATH);
|
||||
if let Ok(existing) = std::fs::read_to_string(path) {
|
||||
let trimmed = existing.trim().to_owned();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed);
|
||||
}
|
||||
}
|
||||
ensure_user_exists("core", true).await?;
|
||||
mint_and_persist_token("core", path).await?;
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
|
||||
Ok(raw.trim().to_owned())
|
||||
}
|
||||
|
||||
/// JSON body for a private, empty repo defaulting to `main`.
|
||||
fn repo_body(name: &str) -> String {
|
||||
format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#)
|
||||
}
|
||||
|
||||
/// POST a repo-creation request to `url` and fold "already exists"
|
||||
/// (HTTP 409 / 422) into success. `label` is `<owner>/<name>` — purely
|
||||
/// for log + error context.
|
||||
async fn create_repo(url: &str, body: &str, token: &str, label: &str) -> Result<()> {
|
||||
let status = forge_http(reqwest::Method::POST, url, token, body).await?;
|
||||
match status.as_u16() {
|
||||
201 => {
|
||||
tracing::info!(%label, "forge: created repo");
|
||||
Ok(())
|
||||
}
|
||||
409 | 422 => {
|
||||
tracing::debug!(%label, "forge: repo already exists");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!("POST {url} ({label}) returned HTTP {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a repo in the token-owner's own namespace. `token` belongs
|
||||
/// to the user we want the repo owned by (we use `core`'s token for
|
||||
/// `core/meta`). Idempotent.
|
||||
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
|
||||
create_repo(
|
||||
&format!("{FORGE_HTTP}/api/v1/user/repos"),
|
||||
&repo_body(name),
|
||||
token,
|
||||
&format!("core/{name}"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create `name` inside org `org` (used for `agent-configs/<agent>`).
|
||||
/// Idempotent.
|
||||
async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> {
|
||||
create_repo(
|
||||
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
|
||||
&repo_body(name),
|
||||
token,
|
||||
&format!("{org}/{name}"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Read the persisted core token, or None when the forge isn't
|
||||
/// seeded yet. Cheap — just a file read.
|
||||
pub fn core_token() -> Option<String> {
|
||||
std::fs::read_to_string(CORE_TOKEN_PATH)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Push `dir` (the meta repo) to `core/meta` on the local forge.
|
||||
/// Best-effort: returns Err which callers log + ignore. No-op when
|
||||
/// the core token isn't present (forge not enabled).
|
||||
pub async fn push_meta(dir: &Path) -> Result<()> {
|
||||
let Some(token) = core_token() else {
|
||||
return Ok(());
|
||||
};
|
||||
// Token-in-URL push. Forgejo accepts `oauth2:<token>` or just
|
||||
// any-username:<token>; using `core` matches the owner so the
|
||||
// remote name is self-describing.
|
||||
let url = format!("http://core:{token}@localhost:3000/core/meta.git");
|
||||
let out = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args(["push", "--force", &url, "HEAD:main"])
|
||||
.output()
|
||||
.await
|
||||
.context("invoke git push core/meta")?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git push core/meta failed ({}): {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
tracing::info!("forge: pushed meta to core/meta");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the `agent-configs/<name>` repo exists so the first
|
||||
/// `push_config` doesn't 404. No-op when the forge isn't running or
|
||||
/// the core token isn't minted yet. Safe to call on every spawn and
|
||||
/// on every startup.
|
||||
pub async fn ensure_config_repo(name: &str) -> Result<()> {
|
||||
if !is_present().await {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(token) = core_token() else {
|
||||
return Ok(());
|
||||
};
|
||||
ensure_org_repo(CONFIG_ORG, name, &token).await
|
||||
}
|
||||
|
||||
/// Grant agent `name` read-only collaborator access to `core/meta` on
|
||||
/// the forge so the agent can clone/fetch the meta flake. Idempotent:
|
||||
/// HTTP 204 (already a collaborator) is treated as success.
|
||||
pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/repos/core/meta/collaborators/{name}");
|
||||
let body = r#"{"permission":"read"}"#;
|
||||
let out = Command::new("curl")
|
||||
.args([
|
||||
"-sS",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"-X",
|
||||
"PUT",
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"-H",
|
||||
&format!("Authorization: token {core_token}"),
|
||||
"-d",
|
||||
body,
|
||||
&url,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("invoke curl PUT core/meta/collaborators")?;
|
||||
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
match code.as_str() {
|
||||
"204" => {
|
||||
tracing::info!(%name, "forge: granted meta read access");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"PUT core/meta/collaborators/{name} returned HTTP {other}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `http://localhost:3000/core/meta.git` as the `meta` remote in
|
||||
/// the agent's proposed config repo so the agent (and the manager) can
|
||||
/// fetch the meta flake from the forge. Idempotent: no-op when the
|
||||
/// remote already points at the right URL, or when the proposed repo
|
||||
/// does not exist yet. No-op when the forge is not running.
|
||||
pub async fn ensure_meta_remote(name: &str) -> Result<()> {
|
||||
if !is_present().await {
|
||||
return Ok(());
|
||||
}
|
||||
let proposed_dir = Coordinator::agent_proposed_dir(name);
|
||||
if !proposed_dir.join(".git").exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let want = format!("{FORGE_HTTP}/core/meta.git");
|
||||
let existing = crate::lifecycle::git_command()
|
||||
.current_dir(&proposed_dir)
|
||||
.args(["remote", "get-url", "meta"])
|
||||
.output()
|
||||
.await
|
||||
.context("git remote get-url meta")?;
|
||||
if existing.status.success() {
|
||||
let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned();
|
||||
if current == want {
|
||||
return Ok(());
|
||||
}
|
||||
crate::lifecycle::git(&proposed_dir, &["remote", "set-url", "meta", &want]).await
|
||||
} else {
|
||||
crate::lifecycle::git(&proposed_dir, &["remote", "add", "meta", &want]).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror agent `name`'s applied config repo — `main` plus every tag
|
||||
/// (`proposal` / `approved` / `building` / `deployed` / `failed` /
|
||||
/// `denied`) — to `agent-configs/<name>` on the local forge.
|
||||
/// Best-effort: returns Err which callers log + ignore. No-op when the
|
||||
/// forge isn't seeded or the applied repo doesn't exist yet.
|
||||
///
|
||||
/// Call this after every hive-c0re mutation of an applied repo's refs
|
||||
/// so the forge copy always reflects what core actually did. `--force`
|
||||
/// because a failed build rolls `main` backwards to the last-good sha.
|
||||
///
|
||||
/// The tokenised URL is passed straight to `git push` and deliberately
|
||||
/// never stored as a named remote: the applied repo is bind-mounted
|
||||
/// READ-ONLY into the manager container (`/applied`), so a token in
|
||||
/// `.git/config` would leak core's admin credential to an agent.
|
||||
pub async fn push_config(name: &str) -> Result<()> {
|
||||
let Some(token) = core_token() else {
|
||||
return Ok(());
|
||||
};
|
||||
let dir = Coordinator::agent_applied_dir(name);
|
||||
if !dir.join(".git").exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let url = format!("http://core:{token}@localhost:3000/{CONFIG_ORG}/{name}.git");
|
||||
let out = crate::lifecycle::git_command()
|
||||
.current_dir(&dir)
|
||||
.args([
|
||||
"push",
|
||||
"--force",
|
||||
&url,
|
||||
"refs/heads/main:refs/heads/main",
|
||||
"refs/tags/*:refs/tags/*",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("invoke git push agent-configs")?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git push {CONFIG_ORG}/{name} failed ({}): {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
tracing::info!(%name, "forge: mirrored applied config to agent-configs");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST `/api/v1/orgs` to create an org named `name`. Idempotent:
|
||||
/// HTTP 422 ("user already exists") is treated as success.
|
||||
async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
|
||||
let body = format!(r#"{{"username":"{name}"}}"#);
|
||||
let url = format!("{FORGE_HTTP}/api/v1/orgs");
|
||||
let status = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?;
|
||||
match status.as_u16() {
|
||||
201 => {
|
||||
tracing::info!(%name, "forge: created org");
|
||||
Ok(())
|
||||
}
|
||||
422 | 409 => {
|
||||
tracing::debug!(%name, "forge: org already exists");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!("POST /api/v1/orgs name={name} returned HTTP {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-agent forge sync: ensure the agent has a forgejo user + token,
|
||||
/// a mirrored config repo, read access to `core/meta`, and the `meta`
|
||||
/// remote in its proposed repo. All operations are idempotent; failures
|
||||
/// are logged as warnings but don't abort the caller.
|
||||
///
|
||||
/// `core_token` is `core_token()` — passed in so callers that already
|
||||
/// fetched it don't re-read the file. Pass `None` to skip the
|
||||
/// `meta_read_access` step (safe: the access grant is best-effort).
|
||||
///
|
||||
/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent`
|
||||
/// (per-rebuild) so the two paths stay equivalent.
|
||||
pub async fn sync_agent(name: &str, core_token: Option<&str>) {
|
||||
if let Err(e) = ensure_user_for(name).await {
|
||||
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
|
||||
}
|
||||
// Align email to match the git user.email set by meta::render_flake
|
||||
// so commits link to the agent's Forgejo profile. Best-effort;
|
||||
// also patches up agents created before this fix (old @hive.local).
|
||||
ensure_user_email(name).await;
|
||||
// Mirror the agent's applied config repo into agent-configs.
|
||||
// ensure_config_repo is idempotent; push_config catches any
|
||||
// drift since the last run — e.g. the startup migration just
|
||||
// relocated `deployed/0`, or a deploy landed while the forge
|
||||
// was down.
|
||||
if let Err(e) = ensure_config_repo(name).await {
|
||||
tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed");
|
||||
}
|
||||
if let Err(e) = push_config(name).await {
|
||||
tracing::warn!(%name, error = ?e, "forge: push_config failed");
|
||||
}
|
||||
// Grant read-only access to core/meta and wire the `meta` remote
|
||||
// into the proposed repo so agents can fetch their deployment context.
|
||||
if let Some(token) = core_token
|
||||
&& let Err(e) = meta_read_access(name, token).await {
|
||||
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
|
||||
}
|
||||
if let Err(e) = ensure_meta_remote(name).await {
|
||||
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweep every existing container (manager + sub-agents) and ensure
|
||||
/// each has a forgejo user + token, plus an `agent-configs/<name>`
|
||||
/// repo mirroring its applied config. Also seeds the `core` admin
|
||||
/// user (hive-c0re's own identity for pushing the meta repo + driving
|
||||
/// the API), the `agent-configs` org, and the `core/meta` repo.
|
||||
/// Called once at hive-c0re startup. Per-step failures are logged
|
||||
/// but don't abort the sweep.
|
||||
pub async fn ensure_all() {
|
||||
if !is_present().await {
|
||||
tracing::debug!("forge: hive-forge container absent, skipping user sweep");
|
||||
return;
|
||||
}
|
||||
let core_token = match ensure_core_user_and_token().await {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "forge: ensure_core_user_and_token failed");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(token) = core_token.as_deref() {
|
||||
for org in SEEDED_ORGS {
|
||||
if let Err(e) = ensure_org(org, token).await {
|
||||
tracing::warn!(%org, error = ?e, "forge: ensure_org failed");
|
||||
}
|
||||
}
|
||||
// Meta repo lives at core/meta — pushed from git_commit in
|
||||
// meta.rs on every deploy/lock-update. Make sure it exists
|
||||
// before the first push hits a 404.
|
||||
if let Err(e) = ensure_repo("meta", token).await {
|
||||
tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed");
|
||||
}
|
||||
if let Err(e) = ensure_core_avatar(token).await {
|
||||
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
|
||||
}
|
||||
if let Err(e) = ensure_config_org_avatar(token).await {
|
||||
tracing::warn!(error = ?e, "forge: ensure_config_org_avatar failed");
|
||||
}
|
||||
}
|
||||
let Ok(containers) = crate::lifecycle::list().await else {
|
||||
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
|
||||
return;
|
||||
};
|
||||
for c in containers {
|
||||
let name = if c == crate::lifecycle::MANAGER_NAME {
|
||||
c
|
||||
} else if let Some(n) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) {
|
||||
n.to_owned()
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
sync_agent(&name, core_token.as_deref()).await;
|
||||
}
|
||||
}
|
||||
1144
hive-c0re/src/lifecycle.rs
Normal file
1144
hive-c0re/src/lifecycle.rs
Normal file
File diff suppressed because it is too large
Load diff
66
hive-c0re/src/limits.rs
Normal file
66
hive-c0re/src/limits.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
//! Wire-protocol size limits shared across the agent + manager
|
||||
//! sockets. Caps on inline message bodies stop a single chatty agent
|
||||
//! (or a misbehaving extra-MCP server) from flooding the broker
|
||||
//! sqlite with megabyte-sized rows that then bloat every recipient's
|
||||
//! wake-prompt context. Anything genuinely larger should be written
|
||||
//! to a state file and the path sent as the body.
|
||||
//!
|
||||
//! Reminders get a separate auto-file escape hatch (see
|
||||
//! `agent_server::handle_remind`) so callers don't have to think
|
||||
//! about it — oversized reminder bodies get persisted to disk
|
||||
//! transparently and the inbox sees a pointer.
|
||||
|
||||
/// Per-message body cap. Applies to `send`, `ask` question text,
|
||||
/// `answer` body, and the stored inline form of a reminder. 4 KiB
|
||||
/// catches the bulk of conversational overflow (status reports,
|
||||
/// bullet-list summaries, short proposals) while staying small
|
||||
/// enough that a backed-up inbox of ~10 unread messages only adds
|
||||
/// ~40 KiB to the recipient's wake-prompt context. Genuinely
|
||||
/// long-form artifacts (audit reports, full diffs, transcripts)
|
||||
/// still belong in a state file — the error message on overflow
|
||||
/// points callers at that escape hatch.
|
||||
pub const MESSAGE_MAX_BYTES: usize = 4096;
|
||||
|
||||
/// Validate that `body` fits under [`MESSAGE_MAX_BYTES`]. Returns a
|
||||
/// caller-ready error string (caller wraps in
|
||||
/// `AgentResponse::Err`/`ManagerResponse::Err`) on failure.
|
||||
///
|
||||
/// `label` shows up in the error message verbatim — pass a short
|
||||
/// noun like `"send"`, `"question"`, `"broadcast"` so the model can
|
||||
/// tell which call got rejected.
|
||||
pub fn check_size(label: &str, body: &str) -> Result<(), String> {
|
||||
if body.len() > MESSAGE_MAX_BYTES {
|
||||
Err(format!(
|
||||
"{label} body too long ({} bytes, max {MESSAGE_MAX_BYTES}); write the \
|
||||
payload to a file under your `/agents/<you>/state/` dir and send the \
|
||||
path as the body instead",
|
||||
body.len()
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_short_body() {
|
||||
assert!(check_size("send", "hello").is_ok());
|
||||
assert!(check_size("send", &"x".repeat(MESSAGE_MAX_BYTES)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversize_body() {
|
||||
let err = check_size("send", &"x".repeat(MESSAGE_MAX_BYTES + 1)).unwrap_err();
|
||||
assert!(err.contains("send body too long"));
|
||||
assert!(err.contains(&format!("max {MESSAGE_MAX_BYTES}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_threads_through() {
|
||||
let err = check_size("question", &"x".repeat(MESSAGE_MAX_BYTES + 1)).unwrap_err();
|
||||
assert!(err.starts_with("question body too long"));
|
||||
}
|
||||
}
|
||||
153
hive-c0re/src/loose_ends.rs
Normal file
153
hive-c0re/src/loose_ends.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
//! Loose-ends aggregator. Walks the `approvals` + `operator_questions`
|
||||
//! tables once per call and assembles a `Vec<LooseEnd>` for either
|
||||
//! a single agent (`for_agent`) or the whole hive (`hive_wide`). Both
|
||||
//! `AgentRequest::GetLooseEnds` and `ManagerRequest::GetLooseEnds`
|
||||
//! land here so the routing logic + age-seconds derivation stay in
|
||||
//! one place.
|
||||
//!
|
||||
//! Call frequency is low (an agent doing self-introspection between
|
||||
//! turns), so the sweep happens fresh every time — no caching, no
|
||||
//! mutation events. If the sweep ever shows up in a profile, the
|
||||
//! sqlite queries already filter on the same indexes
|
||||
//! (`idx_approvals_pending` + `idx_operator_questions_pending`) that
|
||||
//! the dashboard uses, so the bottleneck would be json
|
||||
//! (de)serialisation, not the read.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Result;
|
||||
use hive_sh4re::{MANAGER_AGENT, LooseEnd};
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
/// Open threads pending against `agent`:
|
||||
/// - pending approvals where this agent is the submitter (only ever
|
||||
/// true for the manager — sub-agents don't submit approvals — but
|
||||
/// we keep the rule per-agent so the manager's MCP surface gets
|
||||
/// the same shape via a different code path);
|
||||
/// - unanswered questions where `agent` is the asker (waiting on
|
||||
/// someone) OR the target (owes a reply);
|
||||
/// - pending reminders this agent scheduled (`owner == self`).
|
||||
///
|
||||
/// Ordered approvals → questions → reminders within the returned
|
||||
/// vector. Within each kind, source-of-truth ordering (sqlite's
|
||||
/// `pending()` queries return newest-first within their indexes).
|
||||
pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
|
||||
let now = now_unix();
|
||||
let mut out = Vec::new();
|
||||
// Approvals are only submitted by the manager today. When that
|
||||
// expands (e.g. sub-agents propose changes to their own configs),
|
||||
// teach the approvals table to track the submitter and filter
|
||||
// here on that column — for now MANAGER_AGENT == sole submitter.
|
||||
if agent == MANAGER_AGENT {
|
||||
for a in coord.approvals.pending()? {
|
||||
out.push(LooseEnd::Approval {
|
||||
id: a.id,
|
||||
agent: a.agent,
|
||||
commit_ref: a.commit_ref,
|
||||
description: a.description,
|
||||
age_seconds: saturating_age(now, a.requested_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
for q in coord.questions.pending_all()? {
|
||||
let role_match = q.asker == agent || q.target.as_deref() == Some(agent);
|
||||
if !role_match {
|
||||
continue;
|
||||
}
|
||||
out.push(LooseEnd::Question {
|
||||
id: q.id,
|
||||
asker: q.asker,
|
||||
target: q.target,
|
||||
question: q.question,
|
||||
age_seconds: saturating_age(now, q.asked_at),
|
||||
});
|
||||
}
|
||||
for r in coord.broker.list_pending_reminders()? {
|
||||
if r.agent != agent {
|
||||
continue;
|
||||
}
|
||||
out.push(LooseEnd::Reminder {
|
||||
id: r.id,
|
||||
owner: r.agent,
|
||||
message: r.message,
|
||||
due_at: r.due_at,
|
||||
age_seconds: saturating_age(now, r.created_at),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Hive-wide loose-ends view: EVERY pending approval + EVERY
|
||||
/// unanswered question + EVERY pending reminder. Manager surface
|
||||
/// only; sub-agents can't see each other's threads via the agent
|
||||
/// surface (`for_agent` filters by name).
|
||||
pub fn hive_wide(coord: &Coordinator) -> Result<Vec<LooseEnd>> {
|
||||
let now = now_unix();
|
||||
let mut out = Vec::new();
|
||||
for a in coord.approvals.pending()? {
|
||||
out.push(LooseEnd::Approval {
|
||||
id: a.id,
|
||||
agent: a.agent,
|
||||
commit_ref: a.commit_ref,
|
||||
description: a.description,
|
||||
age_seconds: saturating_age(now, a.requested_at),
|
||||
});
|
||||
}
|
||||
for q in coord.questions.pending_all()? {
|
||||
out.push(LooseEnd::Question {
|
||||
id: q.id,
|
||||
asker: q.asker,
|
||||
target: q.target,
|
||||
question: q.question,
|
||||
age_seconds: saturating_age(now, q.asked_at),
|
||||
});
|
||||
}
|
||||
for r in coord.broker.list_pending_reminders()? {
|
||||
out.push(LooseEnd::Reminder {
|
||||
id: r.id,
|
||||
owner: r.agent,
|
||||
message: r.message,
|
||||
due_at: r.due_at,
|
||||
age_seconds: saturating_age(now, r.created_at),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn saturating_age(now: i64, then: i64) -> u64 {
|
||||
let delta = now.saturating_sub(then);
|
||||
u64::try_from(delta).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn saturating_age_handles_clock_back_step() {
|
||||
// `now` < `then`: caller's clock went backwards between rows.
|
||||
// We saturate to 0 rather than returning a negative or
|
||||
// wrapping around to ~u64::MAX (which would render as "27
|
||||
// billion years ago" in the wake prompt).
|
||||
assert_eq!(saturating_age(100, 200), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturating_age_normal_case() {
|
||||
assert_eq!(saturating_age(1_000_000, 999_990), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturating_age_zero_when_equal() {
|
||||
assert_eq!(saturating_age(42, 42), 0);
|
||||
}
|
||||
}
|
||||
376
hive-c0re/src/main.rs
Normal file
376
hive-c0re/src/main.rs
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use hive_sh4re::{HostRequest, HostResponse};
|
||||
|
||||
mod actions;
|
||||
mod agent_server;
|
||||
mod approvals;
|
||||
mod auto_update;
|
||||
mod broker;
|
||||
mod client;
|
||||
mod container_view;
|
||||
mod coordinator;
|
||||
mod crash_watch;
|
||||
mod dashboard;
|
||||
mod dashboard_events;
|
||||
mod events_vacuum;
|
||||
mod stats_vacuum;
|
||||
mod flake_check;
|
||||
mod forge;
|
||||
mod lifecycle;
|
||||
mod scheduled_prompts;
|
||||
mod scheduled_prompts_worker;
|
||||
mod limits;
|
||||
mod loose_ends;
|
||||
mod manager_server;
|
||||
mod meta;
|
||||
mod migrate;
|
||||
mod operator_questions;
|
||||
mod questions;
|
||||
mod rebuild_queue;
|
||||
mod topology;
|
||||
mod reminder_scheduler;
|
||||
mod server;
|
||||
|
||||
use coordinator::Coordinator;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-c0re", about = "hyperhive coordinator daemon and CLI")]
|
||||
struct Cli {
|
||||
/// Path to the host admin socket.
|
||||
#[arg(long, global = true, default_value = "/run/hyperhive/host.sock")]
|
||||
socket: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Run the coordinator daemon.
|
||||
Serve {
|
||||
/// URL of the hyperhive flake. Inlined into each per-agent
|
||||
/// `flake.nix` as the `hyperhive` input.
|
||||
#[arg(long, default_value = "/etc/hyperhive")]
|
||||
hyperhive_flake: String,
|
||||
/// Path to the sqlite message store.
|
||||
#[arg(long, default_value = "/var/lib/hyperhive/broker.sqlite")]
|
||||
db: PathBuf,
|
||||
/// Dashboard HTTP port.
|
||||
#[arg(long, default_value_t = 7000)]
|
||||
dashboard_port: u16,
|
||||
/// Operator pronouns (free text). Threaded into each
|
||||
/// container's harness via `HIVE_OPERATOR_PRONOUNS` so the
|
||||
/// system prompt can mention them. Default: `she/her`.
|
||||
#[arg(long, default_value = "she/her")]
|
||||
operator_pronouns: String,
|
||||
/// Per-model context-window sizes, as JSON object mapping model-family
|
||||
/// short name to token count. Threaded into each container as
|
||||
/// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars. Set via the
|
||||
/// `services.hive-c0re.contextWindowTokens` NixOS option.
|
||||
#[arg(long, default_value = r#"{"haiku":200000,"sonnet":1000000,"opus":1000000}"#)]
|
||||
context_window_tokens: String,
|
||||
},
|
||||
/// Spawn a new agent container directly (`hive-agent-<name>`). Bypasses
|
||||
/// the approval queue — use only as an operator on the host. For
|
||||
/// approval-gated spawns, use `request-spawn` instead.
|
||||
Spawn { name: String },
|
||||
/// Queue a spawn request as an approval. The container is created on
|
||||
/// `approve <id>` (CLI) or the dashboard's APPR0VE button.
|
||||
RequestSpawn { name: String },
|
||||
/// Stop a managed container (graceful).
|
||||
Kill { name: String },
|
||||
/// Tear down a sub-agent container. Container is removed; persistent
|
||||
/// state (config repos + Claude credentials) is kept by default. Pass
|
||||
/// `--purge` to also wipe the agent's state dirs (config + creds +
|
||||
/// notes). No undo.
|
||||
Destroy {
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
purge: bool,
|
||||
},
|
||||
/// Apply pending config to a managed container.
|
||||
Rebuild { name: String },
|
||||
/// List managed containers.
|
||||
List,
|
||||
/// List pending approval requests submitted by the manager.
|
||||
Pending,
|
||||
/// Approve a pending request by id; the action runs immediately.
|
||||
Approve { id: i64 },
|
||||
/// Deny a pending request by id.
|
||||
Deny { id: i64 },
|
||||
/// Move an agent in the topology tree (#486). Set `--parent` to
|
||||
/// a new parent agent name; pass `--root` to promote the agent
|
||||
/// to root (no parent). Refuses cycles, unknown agents, and
|
||||
/// any attempt to reparent the manager.
|
||||
SetParent {
|
||||
child: String,
|
||||
/// New parent agent name. Mutually exclusive with `--root`.
|
||||
/// Exactly one of `--parent` / `--root` is required — clap
|
||||
/// rejects both-absent calls so a fat-fingered
|
||||
/// `hive-c0re set-parent alice` doesn't silently promote
|
||||
/// alice to root (argus flag on PR #492).
|
||||
#[arg(long, conflicts_with = "root", required_unless_present = "root")]
|
||||
parent: Option<String>,
|
||||
/// Promote `child` to root (no parent).
|
||||
#[arg(long)]
|
||||
root: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
Cmd::Serve {
|
||||
hyperhive_flake,
|
||||
db,
|
||||
dashboard_port,
|
||||
operator_pronouns,
|
||||
context_window_tokens,
|
||||
} => cmd_serve(hyperhive_flake, db, dashboard_port, operator_pronouns, context_window_tokens, &cli.socket).await,
|
||||
Cmd::Spawn { name } => {
|
||||
render(client::request(&cli.socket, HostRequest::Spawn { name }).await?)
|
||||
}
|
||||
Cmd::RequestSpawn { name } => {
|
||||
render(client::request(&cli.socket, HostRequest::RequestSpawn { name }).await?)
|
||||
}
|
||||
Cmd::Kill { name } => {
|
||||
render(client::request(&cli.socket, HostRequest::Kill { name }).await?)
|
||||
}
|
||||
Cmd::Destroy { name, purge } => {
|
||||
render(client::request(&cli.socket, HostRequest::Destroy { name, purge }).await?)
|
||||
}
|
||||
Cmd::Rebuild { name } => {
|
||||
render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?)
|
||||
}
|
||||
Cmd::List => render(client::request(&cli.socket, HostRequest::List).await?),
|
||||
Cmd::Pending => render(client::request(&cli.socket, HostRequest::Pending).await?),
|
||||
Cmd::Approve { id } => {
|
||||
render(client::request(&cli.socket, HostRequest::Approve { id }).await?)
|
||||
}
|
||||
Cmd::Deny { id } => render(client::request(&cli.socket, HostRequest::Deny { id }).await?),
|
||||
Cmd::SetParent {
|
||||
child,
|
||||
parent,
|
||||
root,
|
||||
} => {
|
||||
let new_parent = if root { None } else { parent };
|
||||
render(
|
||||
client::request(
|
||||
&cli.socket,
|
||||
HostRequest::SetParent { child, new_parent },
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the coordinator daemon: open the broker, run migrations, spawn
|
||||
/// background tasks (auto-update, vacuums, crash-watcher, reminder-scheduler,
|
||||
/// dashboard), then serve the admin socket until a signal arrives.
|
||||
async fn cmd_serve(
|
||||
hyperhive_flake: String,
|
||||
db: std::path::PathBuf,
|
||||
dashboard_port: u16,
|
||||
operator_pronouns: String,
|
||||
context_window_tokens: String,
|
||||
socket: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
let cwt: std::collections::HashMap<String, u64> =
|
||||
serde_json::from_str(&context_window_tokens)
|
||||
.context("--context-window-tokens: invalid JSON")?;
|
||||
let coord = Arc::new(Coordinator::open(
|
||||
&db,
|
||||
hyperhive_flake,
|
||||
dashboard_port,
|
||||
operator_pronouns,
|
||||
cwt,
|
||||
)?);
|
||||
manager_server::start(coord.clone())?;
|
||||
// Idempotent pre-flight: rewrite pre-meta-layout applied
|
||||
// repos, ensure proposed repos carry the `applied`
|
||||
// remote, bootstrap the meta repo, repoint containers at
|
||||
// `meta#<name>` (one-shot, guarded by a marker file).
|
||||
// Runs before manager auto-spawn so the new manager is
|
||||
// built against meta from the first attempt.
|
||||
if let Err(e) = migrate::run(&coord).await {
|
||||
tracing::warn!(error = ?e, "startup migration failed");
|
||||
}
|
||||
// Auto-create the manager container if it isn't there yet. Block
|
||||
// on this — without hm1nd the system has no manager harness.
|
||||
// Failures are logged but allowed: a broken auto-spawn shouldn't
|
||||
// make the dashboard unreachable for debugging.
|
||||
if let Err(e) = auto_update::ensure_manager(&coord).await {
|
||||
tracing::warn!(error = ?e, "auto-spawn manager failed");
|
||||
}
|
||||
// Auto-update in the background — don't block service start.
|
||||
// Sub-agent rebuilds can take tens of seconds; we want the admin
|
||||
// socket up immediately.
|
||||
let update_coord = coord.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = auto_update::run(update_coord).await {
|
||||
tracing::warn!(error = ?e, "auto-update task failed");
|
||||
}
|
||||
});
|
||||
// Forge user sweep: ensure every existing container has a
|
||||
// forgejo user + access token. No-op when the hive-forge
|
||||
// container isn't running. Backgrounded — touches the
|
||||
// forge state dir via `nixos-container run` which is slow.
|
||||
tokio::spawn(async move {
|
||||
forge::ensure_all().await;
|
||||
});
|
||||
// Periodic broker vacuum: drop fully-acked messages older
|
||||
// than 30 days. Delivered-but-unacked rows (recoverable via
|
||||
// requeue_inflight) and undelivered rows are always kept.
|
||||
// Runs hourly; first sweep happens immediately.
|
||||
let vacuum_coord = coord.clone();
|
||||
let mut vacuum_shutdown = coord.shutdown_rx();
|
||||
tokio::spawn(async move {
|
||||
let interval = std::time::Duration::from_secs(3600);
|
||||
let keep_secs: i64 = 30 * 24 * 3600;
|
||||
loop {
|
||||
match vacuum_coord.broker.vacuum_delivered(keep_secs) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!(removed = n, "broker vacuum"),
|
||||
Err(e) => tracing::warn!(error = ?e, "broker vacuum failed"),
|
||||
}
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(interval) => {}
|
||||
_ = vacuum_shutdown.changed() => {
|
||||
tracing::info!("broker vacuum: shutdown signal received");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Per-agent events.sqlite vacuum: host-side so the harness
|
||||
// doesn't need any retention wiring of its own.
|
||||
events_vacuum::spawn(&coord);
|
||||
// Per-agent turn-stats.sqlite vacuum: same pattern, 90-day
|
||||
// retention so trend analysis has enough history.
|
||||
stats_vacuum::spawn(&coord);
|
||||
// Container crash watcher: emits HelperEvent::ContainerCrash
|
||||
// when a previously-running container goes away without an
|
||||
// operator-initiated transient state.
|
||||
crash_watch::spawn(coord.clone());
|
||||
// Reminder scheduler: drains due reminders + handles
|
||||
// file_path payload persistence. See reminder_scheduler.rs.
|
||||
reminder_scheduler::spawn(coord.clone());
|
||||
// Scheduled-prompts worker: drains due scheduled_prompts rows
|
||||
// and fans the body out to each active target's inbox. See
|
||||
// scheduled_prompts_worker.rs (#444).
|
||||
scheduled_prompts_worker::spawn(coord.clone());
|
||||
// Rebuild-queue worker: drains the global rebuild/meta-update/
|
||||
// spawn queue FIFO so hive-c0re never runs two heavyweight
|
||||
// container ops concurrently. Existing rebuild call sites
|
||||
// (auto_update, dashboard, manager, approval handler) enqueue
|
||||
// here instead of awaiting `rebuild_agent` inline. See
|
||||
// `rebuild_queue.rs`.
|
||||
{
|
||||
let q_coord = coord.clone();
|
||||
tokio::spawn(async move {
|
||||
rebuild_queue::run_worker(q_coord).await;
|
||||
});
|
||||
}
|
||||
// Forward every broker event onto the unified dashboard
|
||||
// channel with a freshly-stamped seq, so the dashboard SSE
|
||||
// sees broker messages + future mutation events on one
|
||||
// stream with one monotonic seq. The broker's intra-process
|
||||
// channel (used by `recv_blocking_batch`) stays untouched.
|
||||
spawn_broker_to_dashboard_forwarder(coord.clone());
|
||||
let dash_coord = coord.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = dashboard::serve(dashboard_port, dash_coord).await {
|
||||
tracing::error!(error = ?e, "dashboard failed");
|
||||
}
|
||||
});
|
||||
// Run the admin socket until a signal arrives; then signal
|
||||
// all background tasks so they exit cleanly before the
|
||||
// process terminates.
|
||||
let coord_sig = coord.clone();
|
||||
tokio::select! {
|
||||
res = server::serve(socket, coord) => { res? }
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("SIGINT received — requesting shutdown");
|
||||
coord_sig.request_shutdown();
|
||||
}
|
||||
() = async {
|
||||
let mut sig = tokio::signal::unix::signal(
|
||||
tokio::signal::unix::SignalKind::terminate()
|
||||
).expect("failed to install SIGTERM handler");
|
||||
sig.recv().await;
|
||||
} => {
|
||||
tracing::info!("SIGTERM received — requesting shutdown");
|
||||
coord_sig.request_shutdown();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-emit every broker `MessageEvent` onto the dashboard channel as
|
||||
/// a `DashboardEvent::Sent` / `Delivered` with a freshly-stamped seq.
|
||||
/// Background task; runs for the life of the process. On a lagged
|
||||
/// broker subscription we just keep going — the dashboard channel is
|
||||
/// best-effort presentation plumbing, the broker keeps its own sqlite
|
||||
/// log for replay.
|
||||
fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
|
||||
use broker::MessageEvent;
|
||||
use dashboard_events::DashboardEvent;
|
||||
let mut rx = coord.broker.subscribe();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(MessageEvent::Sent { id, from, to, body, at, in_reply_to }) => {
|
||||
let file_refs = dashboard::scan_validated_paths(&body);
|
||||
coord.emit_dashboard_event(DashboardEvent::Sent {
|
||||
seq: coord.next_seq(),
|
||||
id,
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
at,
|
||||
in_reply_to,
|
||||
file_refs,
|
||||
});
|
||||
}
|
||||
Ok(MessageEvent::Delivered { id, from, to, body, at, in_reply_to }) => {
|
||||
let file_refs = dashboard::scan_validated_paths(&body);
|
||||
coord.emit_dashboard_event(DashboardEvent::Delivered {
|
||||
seq: coord.next_seq(),
|
||||
id,
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
at,
|
||||
in_reply_to,
|
||||
file_refs,
|
||||
});
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(skipped = n, "broker-to-dashboard forwarder lagged");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render(resp: HostResponse) -> Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
if !resp.ok {
|
||||
bail!(resp.error.unwrap_or_else(|| "request failed".to_owned()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
1043
hive-c0re/src/manager_server.rs
Normal file
1043
hive-c0re/src/manager_server.rs
Normal file
File diff suppressed because it is too large
Load diff
635
hive-c0re/src/meta.rs
Normal file
635
hive-c0re/src/meta.rs
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
//! Single hive-c0re-owned flake at `/var/lib/hyperhive/meta/` that
|
||||
//! consumes every agent's applied repo as a flake input and exports one
|
||||
//! `nixosConfiguration` per agent. Containers run against
|
||||
//! `--flake /var/lib/hyperhive/meta#<name>`; lifecycle ops here drive the
|
||||
//! lock file so meta's git log is the system-wide deploy audit trail.
|
||||
//!
|
||||
//! Flow:
|
||||
//! - `sync_agents` (idempotent) — render `flake.nix` for the current
|
||||
//! agent set, init the repo on first call, relock if the rendered
|
||||
//! contents changed, commit. Used by spawn / destroy / startup
|
||||
//! migration.
|
||||
//! - `prepare_deploy` + `finalize_deploy` / `abort_deploy` — two-phase
|
||||
//! for the `request_apply_commit` path so a failed
|
||||
//! `nixos-container update` leaves no orphan commit in meta. Prepare
|
||||
//! writes the new lock without committing; finalize commits with the
|
||||
//! deploy message; abort `git restore`s the lock back.
|
||||
//! - `lock_update_hyperhive` — one-shot for the auto-update path.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::lifecycle;
|
||||
|
||||
const META_ROOT: &str = "/var/lib/hyperhive/meta";
|
||||
const APPLIED_ROOT: &str = "/var/lib/hyperhive/applied";
|
||||
const GIT_NAME: &str = "c0re";
|
||||
const GIT_EMAIL: &str = "c0re@hyperhive";
|
||||
|
||||
/// Single-writer lock around every meta-repo operation. Git isn't
|
||||
/// safe to drive from concurrent processes against the same `.git/`
|
||||
/// — two simultaneous `git add` / `commit` invocations race on
|
||||
/// `.git/index.lock`; if either dies before releasing, the lock
|
||||
/// sticks and the next operation hits "another git process seems to
|
||||
/// be running" until somebody `rm`s it manually. Holding this mutex
|
||||
/// across each public function's git+nix calls makes parallel
|
||||
/// rebuilds (`auto_update` + dashboard-triggered + apply-commit)
|
||||
/// take turns instead of colliding.
|
||||
static META_LOCK: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
/// Where the manager sees this directory inside its container (RO bind).
|
||||
#[allow(dead_code)] // wired up by set_nspawn_flags in a follow-up commit
|
||||
pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentSpec {
|
||||
pub name: String,
|
||||
pub is_manager: bool,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn meta_dir() -> PathBuf {
|
||||
PathBuf::from(META_ROOT)
|
||||
}
|
||||
|
||||
/// Idempotently reconcile the meta repo with the current agent set.
|
||||
/// First call inits the git repo, runs `nix flake lock`, and lands a
|
||||
/// seed commit. Subsequent calls only touch `flake.nix` when the
|
||||
/// rendered contents differ from disk; an unchanged `flake.nix` is a
|
||||
/// no-op.
|
||||
#[allow(dead_code)] // first caller lands in a later commit
|
||||
pub async fn sync_agents(
|
||||
hyperhive_flake: &str,
|
||||
dashboard_port: u16,
|
||||
operator_pronouns: &str,
|
||||
context_window_tokens: &std::collections::HashMap<String, u64>,
|
||||
agents: &[AgentSpec],
|
||||
) -> Result<()> {
|
||||
let _guard = META_LOCK.lock().await;
|
||||
let dir = meta_dir();
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
|
||||
let new_flake = render_flake(hyperhive_flake, dashboard_port, operator_pronouns, context_window_tokens, agents);
|
||||
let flake_path = dir.join("flake.nix");
|
||||
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
|
||||
let initial = !dir.join(".git").exists();
|
||||
|
||||
if !initial && on_disk == new_flake {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
std::fs::write(&flake_path, &new_flake)
|
||||
.with_context(|| format!("write {}", flake_path.display()))?;
|
||||
|
||||
// Reconcile topology.json against the live agent set — adds
|
||||
// entries for newly-spawned agents (default: manager as parent,
|
||||
// manager itself as root) and drops removed agents. Operator
|
||||
// overrides via the write API (#361 follow-up) are preserved
|
||||
// because reconcile only fills in missing entries. Idempotent;
|
||||
// when nothing changed the file isn't touched.
|
||||
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
|
||||
let topology_changed = crate::topology::reconcile(&agent_names)
|
||||
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
|
||||
|
||||
if initial {
|
||||
git(&dir, &["init", "--initial-branch=main"]).await?;
|
||||
}
|
||||
// Stage flake.nix *before* running nix flake lock. When meta is
|
||||
// a git repo, nix treats it as a `git+file://` self-reference;
|
||||
// its dirty-tree fetcher includes index entries (tracked +
|
||||
// staged) but skips untracked files, so without the stage step
|
||||
// an untracked flake.nix surfaces as "source tree does not
|
||||
// contain '/flake.nix'". Lock then commit once with both
|
||||
// flake.nix and flake.lock — single commit per change.
|
||||
git(&dir, &["add", "flake.nix"]).await?;
|
||||
// Stage topology.json on every sync (regenerated by reconcile
|
||||
// above when the agent set changed). git add is a no-op when the
|
||||
// file content is unchanged.
|
||||
if crate::topology::topology_path().exists() {
|
||||
git(&dir, &["add", "topology.json"]).await?;
|
||||
}
|
||||
nix(&dir, &["flake", "lock"]).await?;
|
||||
if std::path::Path::new(&dir).join("flake.lock").exists() {
|
||||
git(&dir, &["add", "flake.lock"]).await?;
|
||||
}
|
||||
let msg = if initial {
|
||||
format!("seed meta from {} agent(s)", agents.len())
|
||||
} else if topology_changed {
|
||||
"regenerate meta flake + topology".to_owned()
|
||||
} else {
|
||||
"regenerate meta flake".to_owned()
|
||||
};
|
||||
git_commit(&dir, &msg).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Phase 1 of an apply-commit deploy. Updates the locked rev of
|
||||
/// `agent-<name>` to whatever `applied/<name>/main` currently points
|
||||
/// at and **stages** the lock so `nixos-container update --flake
|
||||
/// meta#<n>` (which reads via `git+file://`) sees the new rev via
|
||||
/// the index. Doesn't commit — `finalize_deploy` commits on build
|
||||
/// success, `abort_deploy` drops the staged change on failure so
|
||||
/// meta history only carries successful deploys.
|
||||
#[allow(dead_code)] // wired up by actions::run_apply_commit in a later commit
|
||||
pub async fn prepare_deploy(name: &str) -> Result<()> {
|
||||
let _guard = META_LOCK.lock().await;
|
||||
let dir = meta_dir();
|
||||
let input = format!("agent-{name}");
|
||||
nix(&dir, &["flake", "update", &input]).await?;
|
||||
// Stage the new lock — git+file://'s dirty-tree fetcher reads
|
||||
// index entries, so the upcoming nixos-container update sees the
|
||||
// bumped rev without a commit yet.
|
||||
git(&dir, &["add", "flake.lock"]).await
|
||||
}
|
||||
|
||||
/// Phase 2-success. Commit the staged lock with the deployed tag +
|
||||
/// sha as the message. No-op when the rev was already at the right
|
||||
/// place (nothing staged → nothing to commit).
|
||||
#[allow(dead_code)]
|
||||
pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> {
|
||||
let _guard = META_LOCK.lock().await;
|
||||
let dir = meta_dir();
|
||||
if !has_staged_changes(&dir).await? {
|
||||
return Ok(());
|
||||
}
|
||||
let short = &sha[..sha.len().min(12)];
|
||||
git_commit(&dir, &format!("deploy {name} {tag} {short}")).await
|
||||
}
|
||||
|
||||
/// Phase 2-failure. Unstage + restore the lock so meta returns to
|
||||
/// the previously-committed shas. The failed proposal is still
|
||||
/// captured in `applied/<n>`'s annotated `failed/<id>` tag.
|
||||
#[allow(dead_code)]
|
||||
pub async fn abort_deploy() -> Result<()> {
|
||||
let _guard = META_LOCK.lock().await;
|
||||
let dir = meta_dir();
|
||||
git(&dir, &["restore", "--staged", "flake.lock"]).await?;
|
||||
git(&dir, &["restore", "flake.lock"]).await
|
||||
}
|
||||
|
||||
async fn has_staged_changes(dir: &Path) -> Result<bool> {
|
||||
let st = lifecycle::git_command()
|
||||
.current_dir(dir)
|
||||
.args(["diff", "--cached", "--quiet"])
|
||||
.status()
|
||||
.await
|
||||
.with_context(|| format!("git diff --cached in {}", dir.display()))?;
|
||||
// exit 1 = differences present, 0 = no diff, other = error
|
||||
match st.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(1) => Ok(true),
|
||||
_ => bail!("git diff --cached exited unexpectedly"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot used by the manual-rebuild path: relock just one
|
||||
/// agent's input and commit the lock change if any. Single-phase
|
||||
/// (no separate finalize) because rebuild has no failure-revert
|
||||
/// semantics — it always wants the latest main.
|
||||
#[allow(dead_code)] // wired up by lifecycle::rebuild in this commit
|
||||
pub async fn lock_update_for_rebuild(name: &str) -> Result<()> {
|
||||
let _guard = META_LOCK.lock().await;
|
||||
let dir = meta_dir();
|
||||
let input = format!("agent-{name}");
|
||||
nix(&dir, &["flake", "update", &input]).await?;
|
||||
if git_is_clean(&dir).await? {
|
||||
return Ok(());
|
||||
}
|
||||
git(&dir, &["add", "flake.lock"]).await?;
|
||||
git_commit(&dir, &format!("rebuild {name}: lock update")).await
|
||||
}
|
||||
|
||||
/// Update one or more named inputs in the meta flake and commit
|
||||
/// the resulting lock change with a single combined message.
|
||||
/// Used by the dashboard's "update meta inputs" form so the
|
||||
/// operator can bulk-bump `hyperhive` + selected agents in one
|
||||
/// shot. Each input name is passed verbatim to
|
||||
/// Run `nix flake update [inputs...]` on the meta flake and commit the
|
||||
/// resulting lock changes. When `inputs` is empty, updates ALL inputs
|
||||
/// (bare `nix flake update`). The caller is responsible for picking
|
||||
/// real input keys (e.g. via `inputs_view()` snapshotted from the lock
|
||||
/// file) when targeting specific inputs.
|
||||
pub async fn lock_update(inputs: &[String]) -> Result<()> {
|
||||
let _guard = META_LOCK.lock().await;
|
||||
let dir = meta_dir();
|
||||
let mut args: Vec<&str> = vec!["flake", "update"];
|
||||
for i in inputs {
|
||||
args.push(i.as_str());
|
||||
}
|
||||
nix(&dir, &args).await?;
|
||||
if git_is_clean(&dir).await? {
|
||||
return Ok(());
|
||||
}
|
||||
git(&dir, &["add", "flake.lock"]).await?;
|
||||
let msg = if inputs.is_empty() {
|
||||
"lock update: all inputs".to_string()
|
||||
} else if inputs.len() == 1 {
|
||||
format!("lock update: {}", inputs[0])
|
||||
} else {
|
||||
format!("lock update: {}", inputs.join(", "))
|
||||
};
|
||||
git_commit(&dir, &msg).await
|
||||
}
|
||||
|
||||
/// One-shot used by the auto-update path: pin the latest hyperhive
|
||||
/// rev, commit if the lock changed. Cheaper than `sync_agents`
|
||||
/// because the per-agent inputs aren't touched.
|
||||
#[allow(dead_code)]
|
||||
pub async fn lock_update_hyperhive() -> Result<()> {
|
||||
let _guard = META_LOCK.lock().await;
|
||||
let dir = meta_dir();
|
||||
nix(&dir, &["flake", "update", "hyperhive"]).await?;
|
||||
if git_is_clean(&dir).await? {
|
||||
return Ok(());
|
||||
}
|
||||
git(&dir, &["add", "flake.lock"]).await?;
|
||||
git_commit(&dir, "bump hyperhive").await
|
||||
}
|
||||
|
||||
fn render_flake(
|
||||
hyperhive_flake: &str,
|
||||
dashboard_port: u16,
|
||||
operator_pronouns: &str,
|
||||
context_window_tokens: &std::collections::HashMap<String, u64>,
|
||||
agents: &[AgentSpec],
|
||||
) -> String {
|
||||
render_flake_with_lookup(
|
||||
hyperhive_flake,
|
||||
dashboard_port,
|
||||
operator_pronouns,
|
||||
context_window_tokens,
|
||||
agents,
|
||||
agent_canonical_inputs,
|
||||
)
|
||||
}
|
||||
|
||||
/// Canonical inputs meta knows how to dedup. An agent that declares one
|
||||
/// of these as a top-level input in its own `flake.nix` will get a
|
||||
/// `follows = "<name>"` line emitted in meta — collapsing the
|
||||
/// otherwise-separate-but-identical `nixpkgs_N` nodes into a single
|
||||
/// meta-level reference (#355).
|
||||
const CANONICAL_INPUTS: &[&str] = &["nixpkgs", "nixpkgs-unstable"];
|
||||
|
||||
/// Read an agent's applied `flake.lock` and return the subset of
|
||||
/// `CANONICAL_INPUTS` it declares as direct (root-level) inputs.
|
||||
/// Returns an empty vec when the lock is missing or unparsable —
|
||||
/// safe degradation, the worst case is no dedup for that agent.
|
||||
fn agent_canonical_inputs(name: &str) -> Vec<&'static str> {
|
||||
let path = std::path::PathBuf::from(format!("{APPLIED_ROOT}/{name}/flake.lock"));
|
||||
let Ok(raw) = std::fs::read_to_string(&path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(root_inputs) = nodes
|
||||
.get(root_name)
|
||||
.and_then(|n| n.get("inputs"))
|
||||
.and_then(|v| v.as_object())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
CANONICAL_INPUTS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|canon| root_inputs.contains_key(*canon))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Inner render helper accepting a lookup fn so tests can stub the
|
||||
/// agent flake-lock introspection.
|
||||
fn render_flake_with_lookup<F>(
|
||||
hyperhive_flake: &str,
|
||||
dashboard_port: u16,
|
||||
operator_pronouns: &str,
|
||||
context_window_tokens: &std::collections::HashMap<String, u64>,
|
||||
agents: &[AgentSpec],
|
||||
lookup: F,
|
||||
) -> String
|
||||
where
|
||||
F: Fn(&str) -> Vec<&'static str>,
|
||||
{
|
||||
use std::fmt::Write as _;
|
||||
let mut out = String::new();
|
||||
out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n");
|
||||
// Pin canonical nixpkgs revisions at the meta level so every input
|
||||
// that pulls a nixpkgs sub-input can `follows = "nixpkgs"` and
|
||||
// collapse to one shared node (closes #317). hyperhive's own
|
||||
// flake.nix picks `nixos-25.11`; we mirror that here so meta and
|
||||
// hyperhive don't diverge into two stable channels by default.
|
||||
// Operator can override these at the meta layer to slide every
|
||||
// dependent agent onto a different channel in one move.
|
||||
out.push_str(" nixpkgs.url = \"github:NixOS/nixpkgs/nixos-25.11\";\n");
|
||||
out.push_str(" nixpkgs-unstable.url = \"github:NixOS/nixpkgs/nixpkgs-unstable\";\n");
|
||||
let _ = writeln!(out, " hyperhive.url = \"{hyperhive_flake}\";");
|
||||
// Collapse hyperhive's own `nixpkgs` + `nixpkgs-unstable` inputs
|
||||
// into meta's. Without this, hyperhive's flake.nix declarations
|
||||
// become independent `nixpkgs_N` nodes in meta/flake.lock.
|
||||
out.push_str(" hyperhive.inputs.nixpkgs.follows = \"nixpkgs\";\n");
|
||||
out.push_str(" hyperhive.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\";\n");
|
||||
for spec in agents {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" agent-{}.url = \"git+file://{APPLIED_ROOT}/{}\";",
|
||||
spec.name, spec.name,
|
||||
);
|
||||
// For each canonical input the agent declares in its own
|
||||
// `flake.nix` (detected by reading its applied `flake.lock`),
|
||||
// emit `inputs.agent-<name>.inputs.<canon>.follows = "<canon>"`.
|
||||
// Collapses three otherwise-separate-but-identical nixpkgs
|
||||
// nodes (root + agent-bitburner's + agent-dmatrix's) into one
|
||||
// (closes #355). Skipped silently for agents that don't
|
||||
// declare the input — emitting follows on a non-existent
|
||||
// input would error at `nix flake lock` time.
|
||||
for canon in lookup(&spec.name) {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" agent-{}.inputs.{canon}.follows = \"{canon}\";",
|
||||
spec.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
out.push_str(" };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n");
|
||||
// Free-text operator string — escape backslash + double-quote so a
|
||||
// pronouns value like `he/him \ "rare"` round-trips into a valid
|
||||
// nix string literal without breaking the flake.
|
||||
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null }}:"
|
||||
);
|
||||
out.push_str(
|
||||
r#" let
|
||||
base = if isManager
|
||||
then hyperhive.nixosConfigurations.manager
|
||||
else hyperhive.nixosConfigurations.agent-base;
|
||||
input = inputs."agent-${name}";
|
||||
service = if isManager then "hive-m1nd" else "hive-ag3nt";
|
||||
parentEnv = if parent == null then {} else { HIVE_PARENT = parent; };
|
||||
in
|
||||
base.extendModules {
|
||||
modules = [
|
||||
input.nixosModules.default
|
||||
{
|
||||
programs.git.config.user = {
|
||||
name = name;
|
||||
email = "${name}@hyperhive";
|
||||
};
|
||||
# Container-wide env: every service + co-process daemon can
|
||||
# resolve the agent's durable state dir without hard-coding it.
|
||||
environment.variables = {
|
||||
HIVE_LABEL = name;
|
||||
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
|
||||
};
|
||||
systemd.services.${service}.environment = parentEnv // {
|
||||
HIVE_PORT = toString port;
|
||||
HIVE_LABEL = name;
|
||||
HIVE_DASHBOARD_PORT = toString dashboardPort;
|
||||
HIVE_OPERATOR_PRONOUNS = operatorPronouns;"#,
|
||||
);
|
||||
// Per-model context-window env vars declared in the host-level
|
||||
// `services.hive-c0re.contextWindowTokens` option. Use a sorted
|
||||
// iterator for deterministic flake output (no spurious git diffs).
|
||||
let mut sorted_tokens: Vec<(&String, &u64)> = context_window_tokens.iter().collect();
|
||||
sorted_tokens.sort_by_key(|(k, _)| k.as_str());
|
||||
for (key, val) in &sorted_tokens {
|
||||
let upper_key = key.to_ascii_uppercase();
|
||||
let _ = writeln!(out, " HIVE_CONTEXT_WINDOW_TOKENS_{upper_key} = \"{val}\";");
|
||||
}
|
||||
// Forge URL — injected when hive-c0re itself has HIVE_FORGE_URL set
|
||||
// (the NixOS module derives it from hyperhive.forge.{domain,httpPort}).
|
||||
// Agents use it in forge_notify to poll Forgejo for PR/review events.
|
||||
if let Ok(forge_url) = std::env::var("HIVE_FORGE_URL")
|
||||
&& !forge_url.is_empty() {
|
||||
let escaped = forge_url.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let _ = writeln!(out, " HIVE_FORGE_URL = \"{escaped}\";");
|
||||
}
|
||||
out.push_str(
|
||||
r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
nixosConfigurations = {
|
||||
"#,
|
||||
);
|
||||
// Pull the topology map once and look up each agent's parent. An
|
||||
// empty / absent topology.json yields `parent = null` for everyone
|
||||
// — equivalent to the pre-#361 status quo (every container at root).
|
||||
// `meta::sync_agents` seeds the file on first run with manager as
|
||||
// root + everyone else under manager.
|
||||
let topology = crate::topology::read();
|
||||
for spec in agents {
|
||||
let parent_attr = topology
|
||||
.get(&spec.name)
|
||||
.and_then(|p| p.as_ref())
|
||||
.map_or_else(|| "null".to_owned(), |p| format!("\"{p}\""));
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; }};",
|
||||
spec.name,
|
||||
spec.name,
|
||||
if spec.is_manager { "true" } else { "false" },
|
||||
spec.port,
|
||||
parent_attr,
|
||||
);
|
||||
}
|
||||
out.push_str(" };\n };\n}\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_spec(name: &str, is_manager: bool, port: u16) -> AgentSpec {
|
||||
AgentSpec {
|
||||
name: name.to_owned(),
|
||||
is_manager,
|
||||
port,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_flake_declares_canonical_nixpkgs() {
|
||||
let out = render_flake(
|
||||
"github:example/hyperhive",
|
||||
8000,
|
||||
"she/her",
|
||||
&std::collections::HashMap::new(),
|
||||
&[sample_spec("alice", false, 9001)],
|
||||
);
|
||||
// Top-level nixpkgs inputs pinned by meta — every nested
|
||||
// nixpkgs input can follow these instead of resolving its own
|
||||
// (closes #317).
|
||||
assert!(out.contains("nixpkgs.url = \"github:NixOS/nixpkgs/nixos-25.11\""));
|
||||
assert!(out.contains("nixpkgs-unstable.url = \"github:NixOS/nixpkgs/nixpkgs-unstable\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_flake_collapses_hyperhive_nixpkgs_via_follows() {
|
||||
let out = render_flake(
|
||||
"github:example/hyperhive",
|
||||
8000,
|
||||
"she/her",
|
||||
&std::collections::HashMap::new(),
|
||||
&[],
|
||||
);
|
||||
// hyperhive's own `nixpkgs` + `nixpkgs-unstable` declarations
|
||||
// get redirected at meta's. Without these, meta/flake.lock
|
||||
// ends up with separate `nixpkgs_N` nodes for hyperhive's
|
||||
// copy (the pre-#317 status quo).
|
||||
assert!(out.contains("hyperhive.inputs.nixpkgs.follows = \"nixpkgs\""));
|
||||
assert!(out.contains("hyperhive.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_flake_emits_follows_for_agents_declaring_nixpkgs() {
|
||||
// Stub lookup: pretend `bitburner` declares `nixpkgs` at its
|
||||
// root, while `argus` has no canonical inputs at all.
|
||||
let lookup = |name: &str| -> Vec<&'static str> {
|
||||
match name {
|
||||
"bitburner" => vec!["nixpkgs"],
|
||||
"dmatrix" => vec!["nixpkgs", "nixpkgs-unstable"],
|
||||
_ => vec![],
|
||||
}
|
||||
};
|
||||
let out = render_flake_with_lookup(
|
||||
"github:example/hyperhive",
|
||||
8000,
|
||||
"she/her",
|
||||
&std::collections::HashMap::new(),
|
||||
&[
|
||||
sample_spec("argus", false, 9001),
|
||||
sample_spec("bitburner", false, 9002),
|
||||
sample_spec("dmatrix", false, 9003),
|
||||
],
|
||||
lookup,
|
||||
);
|
||||
// bitburner declares nixpkgs → follows emitted.
|
||||
assert!(
|
||||
out.contains("agent-bitburner.inputs.nixpkgs.follows = \"nixpkgs\""),
|
||||
"missing bitburner nixpkgs follows:\n{out}"
|
||||
);
|
||||
// dmatrix declares both → both follows emitted.
|
||||
assert!(out.contains("agent-dmatrix.inputs.nixpkgs.follows = \"nixpkgs\""));
|
||||
assert!(
|
||||
out.contains("agent-dmatrix.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\"")
|
||||
);
|
||||
// argus declares neither → no follows emitted for it. Asserting
|
||||
// ABSENCE is the important bit: emitting a follows on a
|
||||
// non-existent input errors at `nix flake lock` time.
|
||||
assert!(
|
||||
!out.contains("agent-argus.inputs.nixpkgs"),
|
||||
"argus shouldn't have nixpkgs follows:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_flake_skips_canonical_follows_when_lookup_returns_empty() {
|
||||
let out = render_flake_with_lookup(
|
||||
"github:example/hyperhive",
|
||||
8000,
|
||||
"she/her",
|
||||
&std::collections::HashMap::new(),
|
||||
&[sample_spec("alice", false, 9001)],
|
||||
|_| Vec::new(),
|
||||
);
|
||||
// No agent-side follows when the lookup reports nothing
|
||||
// declared — protects agents whose flake.lock can't be read
|
||||
// (missing / unparsable) from being broken by a follows on a
|
||||
// non-existent input.
|
||||
assert!(
|
||||
!out.contains("agent-alice.inputs."),
|
||||
"alice shouldn't have any inputs follows:\n{out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn git_is_clean(dir: &Path) -> Result<bool> {
|
||||
let out = lifecycle::git_command()
|
||||
.current_dir(dir)
|
||||
.args(["status", "--porcelain"])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git status in {}", dir.display()))?;
|
||||
Ok(out.stdout.iter().all(u8::is_ascii_whitespace))
|
||||
}
|
||||
|
||||
async fn git(dir: &Path, args: &[&str]) -> Result<()> {
|
||||
let out = lifecycle::git_command()
|
||||
.current_dir(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"git {} failed ({}): {}",
|
||||
args.join(" "),
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||
git(
|
||||
dir,
|
||||
&[
|
||||
"-c",
|
||||
&format!("user.name={GIT_NAME}"),
|
||||
"-c",
|
||||
&format!("user.email={GIT_EMAIL}"),
|
||||
"commit",
|
||||
"-m",
|
||||
message,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
// Best-effort mirror to the bundled forge. No-op when the forge
|
||||
// isn't seeded (no core token on disk); push failures log a warn
|
||||
// but don't bubble up — a missing mirror shouldn't fail an
|
||||
// otherwise successful deploy.
|
||||
if let Err(e) = crate::forge::push_meta(dir).await {
|
||||
tracing::warn!(error = ?e, "forge: meta push after commit failed (non-fatal)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn nix(dir: &Path, args: &[&str]) -> Result<()> {
|
||||
// `--extra-experimental-features` belt-and-suspenders for hosts
|
||||
// that haven't set this in nix.conf. The hyperhive module's
|
||||
// deploy guide assumes flakes are already enabled, but the cost
|
||||
// of being defensive is one extra argv each call.
|
||||
let mut all = vec!["--extra-experimental-features", "nix-command flakes"];
|
||||
all.extend(args);
|
||||
let out = Command::new("nix")
|
||||
.current_dir(dir)
|
||||
.args(&all)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("nix {} in {}", args.join(" "), dir.display()))?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"nix {} failed ({}): {}",
|
||||
args.join(" "),
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
215
hive-c0re/src/migrate.rs
Normal file
215
hive-c0re/src/migrate.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
//! Startup auto-migration from the pre-meta layout. Runs before
|
||||
//! `auto_update::run` and consists of four phases, each idempotent:
|
||||
//!
|
||||
//! 1. Per-agent applied repo: rewrite `flake.nix` to the module-only
|
||||
//! boilerplate if it isn't already, commit, relocate `deployed/0`
|
||||
//! to HEAD so `setup_applied`'s existence check passes.
|
||||
//! 2. Per-agent proposed repo: ensure the `applied` git remote
|
||||
//! points at `/applied/<n>/.git` (re-runs `setup_proposed`'s
|
||||
//! `ensure_applied_remote` indirectly via a host-side git call).
|
||||
//! 3. Meta repo: `meta::sync_agents` over the current agent list —
|
||||
//! init the repo on first call, rerender + relock if anything
|
||||
//! drifted.
|
||||
//! 4. Container repoint: for every existing container, run
|
||||
//! `nixos-container update <c> --flake meta#<name>` so it
|
||||
//! activates against the meta flake. Guarded by a marker file
|
||||
//! so the (expensive) phase 4 only runs once across hive-c0re
|
||||
//! restarts.
|
||||
//!
|
||||
//! Env kill-switch: `HIVE_SKIP_META_MIGRATION=1` skips the whole
|
||||
//! migration. Use when smoke-testing one agent at a time by hand.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
|
||||
use crate::meta;
|
||||
|
||||
const KILL_SWITCH: &str = "HIVE_SKIP_META_MIGRATION";
|
||||
|
||||
/// Marker for phase 4. Once present, container repoint is skipped on
|
||||
/// future restarts.
|
||||
fn repoint_marker() -> PathBuf {
|
||||
PathBuf::from("/var/lib/hyperhive/.meta-migration-done")
|
||||
}
|
||||
|
||||
/// Substring that identifies the *current* agent flake boilerplate.
|
||||
/// Bumped whenever the template changes so the startup migration
|
||||
/// re-renders existing agents onto the new shape. Today the marker
|
||||
/// is the `flakeInputs` module-arg forwarding line — older templates
|
||||
/// (raw `import ./agent.nix`) get rewritten on next hive-c0re start.
|
||||
const MODULE_FLAKE_MARKER: &str = "_module.args.flakeInputs";
|
||||
|
||||
pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
|
||||
if std::env::var(KILL_SWITCH).is_ok() {
|
||||
tracing::info!("migration: {KILL_SWITCH} set — skipping");
|
||||
return Ok(());
|
||||
}
|
||||
// Stale meta index lock: a previous hive-c0re crash mid-`git add`
|
||||
// can leave `.git/index.lock` behind, which blocks every
|
||||
// subsequent meta op until somebody `rm`s it manually. We just
|
||||
// booted so nothing of ours is holding it; safe to clear.
|
||||
let meta_lock = std::path::PathBuf::from("/var/lib/hyperhive/meta/.git/index.lock");
|
||||
if meta_lock.exists() {
|
||||
match std::fs::remove_file(&meta_lock) {
|
||||
Ok(()) => tracing::warn!("cleared stale meta/.git/index.lock"),
|
||||
Err(e) => tracing::warn!(error = ?e, "clear stale meta lock failed"),
|
||||
}
|
||||
}
|
||||
let names = enumerate_agents().await;
|
||||
tracing::info!(count = names.len(), "migration: scanning");
|
||||
|
||||
// Phase 1 + 2: per-agent applied + proposed.
|
||||
for name in &names {
|
||||
if let Err(e) = migrate_applied_repo(name).await {
|
||||
tracing::warn!(%name, error = ?e, "migration: applied repo rewrite failed");
|
||||
}
|
||||
if let Err(e) =
|
||||
lifecycle::setup_proposed(&Coordinator::agent_proposed_dir(name), name).await
|
||||
{
|
||||
tracing::warn!(%name, error = ?e, "migration: setup_proposed failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: meta repo.
|
||||
let agents = lifecycle::agents_for_meta_listing()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if let Err(e) = meta::sync_agents(
|
||||
&coord.hyperhive_flake,
|
||||
coord.dashboard_port,
|
||||
&coord.operator_pronouns,
|
||||
&coord.context_window_tokens,
|
||||
&agents,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = ?e, "migration: meta sync_agents failed");
|
||||
}
|
||||
|
||||
// Phase 4: container repoint, guarded by marker.
|
||||
if repoint_marker().exists() {
|
||||
tracing::debug!("migration: phase 4 marker present, skipping repoint");
|
||||
return Ok(());
|
||||
}
|
||||
let mut all_ok = true;
|
||||
for name in &names {
|
||||
// Mark Rebuilding so the crash watcher skips this container
|
||||
// during the brief stop+start window the nixos-container
|
||||
// update activation triggers. Without this, crash_watch
|
||||
// would fire ContainerCrash for every agent here and the
|
||||
// manager would spuriously try to recover them.
|
||||
let guard = coord.transient_guard(name, crate::coordinator::TransientKind::Rebuilding);
|
||||
let result = repoint_container(name).await;
|
||||
drop(guard);
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(%name, error = ?e, "migration: container repoint failed");
|
||||
all_ok = false;
|
||||
}
|
||||
}
|
||||
if all_ok
|
||||
&& !names.is_empty()
|
||||
&& let Err(e) = std::fs::write(repoint_marker(), b"done\n")
|
||||
{
|
||||
tracing::warn!(error = ?e, "migration: write repoint marker failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enumerate_agents() -> Vec<String> {
|
||||
let containers = lifecycle::list().await.unwrap_or_default();
|
||||
containers
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
if c == MANAGER_NAME {
|
||||
Some(MANAGER_NAME.to_owned())
|
||||
} else {
|
||||
c.strip_prefix(AGENT_PREFIX).map(str::to_owned)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn migrate_applied_repo(name: &str) -> Result<()> {
|
||||
let dir = Coordinator::agent_applied_dir(name);
|
||||
if !dir.join(".git").exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let flake_path = dir.join("flake.nix");
|
||||
let cur = std::fs::read_to_string(&flake_path).unwrap_or_default();
|
||||
if cur.contains(MODULE_FLAKE_MARKER) {
|
||||
return Ok(());
|
||||
}
|
||||
let want = lifecycle::initial_flake_nix();
|
||||
std::fs::write(&flake_path, want).with_context(|| format!("write {}", flake_path.display()))?;
|
||||
raw_git(
|
||||
&dir,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=c0re",
|
||||
"-c",
|
||||
"user.email=c0re@hyperhive",
|
||||
"add",
|
||||
"flake.nix",
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
raw_git(
|
||||
&dir,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=c0re",
|
||||
"-c",
|
||||
"user.email=c0re@hyperhive",
|
||||
"commit",
|
||||
"-m",
|
||||
"migration: module-only flake",
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
// Relocate deployed/0 to the migration commit so
|
||||
// setup_applied's existence check passes.
|
||||
raw_git(&dir, &["tag", "-f", "deployed/0", "HEAD"]).await?;
|
||||
tracing::info!(%name, "migration: applied repo migrated to module-only flake");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn repoint_container(name: &str) -> Result<()> {
|
||||
let container = lifecycle::container_name(name);
|
||||
let flake_ref = format!("{}#{name}", meta::meta_dir().display());
|
||||
let out = Command::new("nixos-container")
|
||||
.args(["update", &container, "--flake", &flake_ref])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("nixos-container update {container}"))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"nixos-container update {container} exited {}: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
tracing::info!(%name, %container, "migration: container repointed at meta");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn raw_git(dir: &Path, args: &[&str]) -> Result<()> {
|
||||
let out = lifecycle::git_command()
|
||||
.current_dir(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
319
hive-c0re/src/operator_questions.rs
Normal file
319
hive-c0re/src/operator_questions.rs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
//! Question queue. Agents submit via `Ask`; the answer comes from
|
||||
//! either the operator (via the dashboard, for `target IS NULL`) or
|
||||
//! a peer agent (via `Answer`, for agent-to-agent questions).
|
||||
//!
|
||||
//! Despite the file name (kept for git history sanity), this table
|
||||
//! now stores *all* asynchronous questions in the hive — both the
|
||||
//! operator-targeted ones and the peer-to-peer ones. `target IS
|
||||
//! NULL` is the operator path (back-compat with rows written before
|
||||
//! the column existed); `target = '<agent-name>'` is the
|
||||
//! agent-to-agent path.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::Serialize;
|
||||
|
||||
const SCHEMA: &str = r"
|
||||
CREATE TABLE IF NOT EXISTS operator_questions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
asker TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options_json TEXT NOT NULL,
|
||||
asked_at INTEGER NOT NULL,
|
||||
answered_at INTEGER,
|
||||
answer TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
|
||||
ON operator_questions (id) WHERE answered_at IS NULL;
|
||||
";
|
||||
|
||||
/// Add late-added columns to pre-existing databases. `ALTER TABLE
|
||||
/// ADD COLUMN` has no `IF NOT EXISTS` form in sqlite, so we check
|
||||
/// `pragma_table_info` first per column.
|
||||
fn ensure_columns(conn: &Connection) -> Result<()> {
|
||||
for (name, sql) in [
|
||||
(
|
||||
"multi",
|
||||
"ALTER TABLE operator_questions ADD COLUMN multi INTEGER NOT NULL DEFAULT 0;",
|
||||
),
|
||||
(
|
||||
"deadline_at",
|
||||
"ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER;",
|
||||
),
|
||||
// `target` = recipient of the question. NULL = operator
|
||||
// (back-compat default for rows written before agent-to-agent
|
||||
// questions existed); a non-null agent name = peer-to-peer
|
||||
// question. Dashboard's `pending()` filters on `target IS NULL`
|
||||
// so peer questions never leak into the operator's queue.
|
||||
(
|
||||
"target",
|
||||
"ALTER TABLE operator_questions ADD COLUMN target TEXT;",
|
||||
),
|
||||
] {
|
||||
let has: bool = conn
|
||||
.prepare(&format!(
|
||||
"SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = '{name}'"
|
||||
))?
|
||||
.exists([])?;
|
||||
if !has {
|
||||
conn.execute_batch(sql)
|
||||
.with_context(|| format!("add operator_questions.{name} column"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[allow(clippy::doc_markdown)]
|
||||
pub struct OpQuestion {
|
||||
pub id: i64,
|
||||
pub asker: String,
|
||||
pub question: String,
|
||||
pub options: Vec<String>,
|
||||
pub multi: bool,
|
||||
pub asked_at: i64,
|
||||
/// Absolute unix-seconds deadline after which a watchdog auto-
|
||||
/// resolves the question with answer `[expired]`. `None` = no
|
||||
/// expiry. Surfaced on the dashboard as a remaining-time chip.
|
||||
pub deadline_at: Option<i64>,
|
||||
pub answered_at: Option<i64>,
|
||||
pub answer: Option<String>,
|
||||
/// Recipient of the question. `None` = the operator (dashboard
|
||||
/// path); `Some(<agent>)` = a peer agent asked via
|
||||
/// `Ask { to: Some(<agent>), ... }`. Agent-to-agent questions
|
||||
/// never appear in `pending()` so the operator's queue stays clean.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
pub struct OperatorQuestions {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl OperatorQuestions {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("create operator_questions db parent {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let conn = Connection::open(path)
|
||||
.with_context(|| format!("open operator_questions db {}", path.display()))?;
|
||||
conn.execute_batch(SCHEMA)
|
||||
.context("apply operator_questions schema")?;
|
||||
ensure_columns(&conn).context("migrate operator_questions columns")?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn submit(
|
||||
&self,
|
||||
asker: &str,
|
||||
question: &str,
|
||||
options: &[String],
|
||||
multi: bool,
|
||||
deadline_at: Option<i64>,
|
||||
target: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into());
|
||||
conn.execute(
|
||||
"INSERT INTO operator_questions
|
||||
(asker, question, options_json, multi, deadline_at, target, asked_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
asker,
|
||||
question,
|
||||
options_json,
|
||||
i64::from(multi),
|
||||
deadline_at,
|
||||
target,
|
||||
now_unix(),
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Mark a pending question answered. `answerer` is who's actually
|
||||
/// answering: `"operator"` for the dashboard path, or an agent's
|
||||
/// own name when responding via `Answer`. Authorisation:
|
||||
///
|
||||
/// - Operator-targeted questions (`target IS NULL`) can only be
|
||||
/// answered by `"operator"`. (Agents must not be able to spoof
|
||||
/// answers to operator questions — the dashboard is the
|
||||
/// privileged path.)
|
||||
/// - Agent-targeted questions can only be answered by the
|
||||
/// declared target agent, OR by `"operator"` (operator override
|
||||
/// for stuck threads — useful when an agent is offline/down
|
||||
/// and someone has to close the loop).
|
||||
///
|
||||
/// Returns `(question, asker, target)` so the caller can fire the
|
||||
/// `QuestionAnswered` event with the right answerer label and route
|
||||
/// it back to the original asker.
|
||||
pub fn answer(
|
||||
&self,
|
||||
id: i64,
|
||||
answer: &str,
|
||||
answerer: &str,
|
||||
) -> Result<(String, String, Option<String>)> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let row: Option<(String, String, Option<String>, Option<i64>)> = conn
|
||||
.query_row(
|
||||
"SELECT question, asker, target, answered_at FROM operator_questions WHERE id = ?1",
|
||||
params![id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((question, asker, target, answered_at)) = row else {
|
||||
bail!("question {id} not found");
|
||||
};
|
||||
if answered_at.is_some() {
|
||||
bail!("question {id} already answered");
|
||||
}
|
||||
// Authorisation check: must match the target, or be the operator
|
||||
// (operator-targeted questions are operator-only; the operator
|
||||
// can additionally override agent-to-agent questions to close
|
||||
// stuck threads).
|
||||
let authorised = match target.as_deref() {
|
||||
None => answerer == hive_sh4re::OPERATOR_RECIPIENT,
|
||||
Some(t) => answerer == t || answerer == hive_sh4re::OPERATOR_RECIPIENT,
|
||||
};
|
||||
if !authorised {
|
||||
bail!(
|
||||
"question {id} not addressed to '{answerer}' (target = {:?})",
|
||||
target.as_deref().unwrap_or(hive_sh4re::OPERATOR_RECIPIENT)
|
||||
);
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
|
||||
params![answer, now_unix(), id],
|
||||
)?;
|
||||
Ok((question, asker, target))
|
||||
}
|
||||
|
||||
/// Cancel a pending question on behalf of `canceller`. Returns
|
||||
/// `(question, asker, target)` so the caller can fire the usual
|
||||
/// `QuestionAnswered` event to the asker with a `[cancelled by
|
||||
/// <canceller>]` sentinel.
|
||||
///
|
||||
/// Auth: the canceller must be one of:
|
||||
/// - the original asker (an agent withdrawing their own ask),
|
||||
/// - the operator (already covered by the existing `answer` path
|
||||
/// but allowed here too for symmetry / dashboard cancel),
|
||||
/// - the manager (privileged hive-wide cleanup).
|
||||
///
|
||||
/// Not the target — that's covered by `answer` (responding with
|
||||
/// an actual reply, sentinel or otherwise).
|
||||
pub fn cancel(
|
||||
&self,
|
||||
id: i64,
|
||||
canceller: &str,
|
||||
) -> Result<(String, String, Option<String>)> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let row: Option<(String, String, Option<String>, Option<i64>)> = conn
|
||||
.query_row(
|
||||
"SELECT question, asker, target, answered_at FROM operator_questions WHERE id = ?1",
|
||||
params![id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((question, asker, target, answered_at)) = row else {
|
||||
bail!("question {id} not found");
|
||||
};
|
||||
if answered_at.is_some() {
|
||||
bail!("question {id} already answered/cancelled");
|
||||
}
|
||||
let authorised = canceller == asker
|
||||
|| canceller == hive_sh4re::OPERATOR_RECIPIENT
|
||||
|| canceller == hive_sh4re::MANAGER_AGENT;
|
||||
if !authorised {
|
||||
bail!(
|
||||
"question {id}: '{canceller}' not allowed to cancel (asker = '{asker}')"
|
||||
);
|
||||
}
|
||||
let sentinel = format!("[cancelled by {canceller}]");
|
||||
conn.execute(
|
||||
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
|
||||
params![sentinel, now_unix(), id],
|
||||
)?;
|
||||
Ok((question, asker, target))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
|
||||
FROM operator_questions WHERE id = ?1",
|
||||
params![id],
|
||||
row_to_question,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Every pending question, operator-targeted or peer-to-peer.
|
||||
/// Drives the dashboard's questions pane now that peer threads
|
||||
/// are surfaced for visibility + operator override-answer.
|
||||
pub fn pending_all(&self) -> Result<Vec<OpQuestion>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
|
||||
FROM operator_questions
|
||||
WHERE answered_at IS NULL
|
||||
ORDER BY id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], row_to_question)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Last `limit` answered questions across both target kinds,
|
||||
/// newest-first. Companion to `pending_all`.
|
||||
pub fn recent_answered_all(&self, limit: u64) -> Result<Vec<OpQuestion>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
|
||||
FROM operator_questions
|
||||
WHERE answered_at IS NOT NULL
|
||||
ORDER BY answered_at DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
|
||||
let rows = stmt.query_map(params![limit_i], row_to_question)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
|
||||
let options_json: String = row.get(3)?;
|
||||
let options: Vec<String> = serde_json::from_str(&options_json).unwrap_or_default();
|
||||
let multi: i64 = row.get(4)?;
|
||||
Ok(OpQuestion {
|
||||
id: row.get(0)?,
|
||||
asker: row.get(1)?,
|
||||
question: row.get(2)?,
|
||||
options,
|
||||
multi: multi != 0,
|
||||
asked_at: row.get(5)?,
|
||||
answered_at: row.get(6)?,
|
||||
answer: row.get(7)?,
|
||||
deadline_at: row.get(8)?,
|
||||
target: row.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
257
hive-c0re/src/questions.rs
Normal file
257
hive-c0re/src/questions.rs
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
//! Shared dispatch helpers for the `Ask` / `Answer` flow. Both the
|
||||
//! agent socket and the manager socket call into here so the routing
|
||||
//! semantics — recipient = operator vs. peer agent, answerer
|
||||
//! authorisation, asker-notification — only live in one place.
|
||||
//!
|
||||
//! Routing rules at a glance:
|
||||
//!
|
||||
//! - `Ask { to: None | Some("operator") }` → stored with `target = NULL`;
|
||||
//! the dashboard's `pending()` query surfaces it; operator answers
|
||||
//! via the dashboard.
|
||||
//! - `Ask { to: Some(<agent>) }` → stored with `target = <agent>`;
|
||||
//! a `HelperEvent::QuestionAsked` is pushed into `<agent>`'s
|
||||
//! inbox so they can `Answer { id, answer }` on their own socket.
|
||||
//! - `Answer { id, answer }` → permission-checked in
|
||||
//! `OperatorQuestions::answer` (only the target agent or the
|
||||
//! operator can answer; both paths fire the same
|
||||
//! `QuestionAnswered` event to the asker).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::approvals::kind_to_str;
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::limits;
|
||||
use crate::manager_server::spawn_question_watchdog;
|
||||
|
||||
/// Cap on how long an asker can demand an answer before the watchdog
|
||||
/// auto-resolves with `[expired]`. Six hours mirrors typical agent
|
||||
/// session lifetimes — beyond that an unanswered question is
|
||||
/// effectively a dead thread and should be re-asked, not blocked on.
|
||||
const MAX_TTL_SECONDS: u64 = 6 * 60 * 60;
|
||||
|
||||
/// Handle either surface's `Ask` request. Returns the queued
|
||||
/// question id on success or a caller-ready error string. Caller is
|
||||
/// responsible for wrapping in the matching `*Response::Err` /
|
||||
/// `QuestionQueued` variant.
|
||||
pub fn handle_ask(
|
||||
coord: &Arc<Coordinator>,
|
||||
asker: &str,
|
||||
question: &str,
|
||||
options: &[String],
|
||||
multi: bool,
|
||||
ttl_seconds: Option<u64>,
|
||||
to: Option<&str>,
|
||||
) -> Result<i64, String> {
|
||||
limits::check_size("question", question)?;
|
||||
// Normalise `Some("operator")` → None so the storage layer
|
||||
// only has to think about NULL vs. non-NULL targets, not
|
||||
// "is this string the operator?".
|
||||
let target = match to {
|
||||
None => None,
|
||||
Some(t) if t == hive_sh4re::OPERATOR_RECIPIENT => None,
|
||||
Some("") => {
|
||||
return Err("ask: `to` cannot be empty (omit it for the operator path)".to_owned());
|
||||
}
|
||||
Some(t) if t == asker => {
|
||||
return Err("ask: cannot ask yourself a question (would loop forever)".to_owned());
|
||||
}
|
||||
Some(t) => Some(t),
|
||||
};
|
||||
let ttl = ttl_seconds.map(|s| s.min(MAX_TTL_SECONDS));
|
||||
let deadline_at = ttl.and_then(|s| {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0);
|
||||
i64::try_from(s).ok().map(|s| now + s)
|
||||
});
|
||||
let id = coord
|
||||
.questions
|
||||
.submit(asker, question, options, multi, deadline_at, target)
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
tracing::info!(%id, %asker, ?target, ?deadline_at, "question queued");
|
||||
// Agent-targeted questions need to wake the recipient — drop a
|
||||
// QuestionAsked event into their inbox so the answerer doesn't
|
||||
// have to poll. Operator-targeted questions show up on the
|
||||
// dashboard's pending pane via `pending()` instead, plus a
|
||||
// `QuestionAdded` dashboard event so the browser updates live.
|
||||
if let Some(target_agent) = target {
|
||||
coord.notify_agent(
|
||||
target_agent,
|
||||
&hive_sh4re::HelperEvent::QuestionAsked {
|
||||
id,
|
||||
asker: asker.to_owned(),
|
||||
question: question.to_owned(),
|
||||
options: options.to_vec(),
|
||||
multi,
|
||||
},
|
||||
);
|
||||
}
|
||||
// Always fire on the dashboard channel — both operator-targeted
|
||||
// and peer threads now surface in the dashboard's questions pane.
|
||||
coord.emit_question_added(id, asker, question, options, multi, deadline_at, target);
|
||||
if let Some(t) = ttl {
|
||||
spawn_question_watchdog(coord, id, t);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Handle either surface's `Answer` request. Returns `Ok(())` on
|
||||
/// success or a caller-ready error string. Authorisation lives in
|
||||
/// `OperatorQuestions::answer` — we only have to wire the result
|
||||
/// back to the asker as a `QuestionAnswered` event.
|
||||
pub fn handle_answer(
|
||||
coord: &Arc<Coordinator>,
|
||||
answerer: &str,
|
||||
id: i64,
|
||||
answer: &str,
|
||||
) -> Result<(), String> {
|
||||
limits::check_size("answer", answer)?;
|
||||
let (question, asker, target) = coord
|
||||
.questions
|
||||
.answer(id, answer, answerer)
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
tracing::info!(%id, %answerer, %asker, "question answered");
|
||||
// Use answerer as the broker `from` so the asker's terminal shows
|
||||
// the real name (agent or "operator") instead of "system".
|
||||
coord.notify_agent_from(
|
||||
answerer,
|
||||
&asker,
|
||||
&hive_sh4re::HelperEvent::QuestionAnswered {
|
||||
id,
|
||||
question,
|
||||
answer: answer.to_owned(),
|
||||
answerer: answerer.to_owned(),
|
||||
},
|
||||
);
|
||||
// Dashboard surfaces both operator-targeted and peer threads;
|
||||
// emit unconditionally so the derived store moves the row.
|
||||
// `cancelled = false` because this path is a real answer (the
|
||||
// operator-cancel button goes through `post_cancel_question`).
|
||||
coord.emit_question_resolved(id, answer, answerer, false, target.as_deref());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `CancelLooseEnd` from either surface. Dispatches by kind to
|
||||
/// either `OperatorQuestions::cancel` or `Broker::cancel_reminder_as`,
|
||||
/// both of which do their own auth check (canceller == owner /
|
||||
/// asker, or `operator`, or `manager`). On question cancel, fires
|
||||
/// the `QuestionAnswered` event back to the asker so the harness
|
||||
/// loop can react (mirrors the operator-cancel dashboard path).
|
||||
pub fn handle_cancel_loose_end(
|
||||
coord: &Arc<Coordinator>,
|
||||
canceller: &str,
|
||||
kind: hive_sh4re::CancelLooseEndKind,
|
||||
id: i64,
|
||||
) -> Result<(), String> {
|
||||
match kind {
|
||||
hive_sh4re::CancelLooseEndKind::Question => {
|
||||
let (question, asker, target) = coord
|
||||
.questions
|
||||
.cancel(id, canceller)
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
let sentinel = format!("[cancelled by {canceller}]");
|
||||
tracing::info!(%id, %canceller, %asker, "question cancelled");
|
||||
// Only notify the asker if they didn't cancel it themselves.
|
||||
// Self-cancels are already known to the canceller — sending
|
||||
// a QuestionAnswered back would cause the harness to process
|
||||
// its own cancel as an incoming answer.
|
||||
if asker != canceller {
|
||||
coord.notify_agent_from(
|
||||
canceller,
|
||||
&asker,
|
||||
&hive_sh4re::HelperEvent::QuestionAnswered {
|
||||
id,
|
||||
question,
|
||||
answer: sentinel.clone(),
|
||||
answerer: canceller.to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
coord.emit_question_resolved(id, &sentinel, canceller, true, target.as_deref());
|
||||
Ok(())
|
||||
}
|
||||
hive_sh4re::CancelLooseEndKind::Reminder => {
|
||||
let owner = coord
|
||||
.broker
|
||||
.cancel_reminder_as(id, canceller)
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
tracing::info!(%id, %canceller, %owner, "reminder cancelled");
|
||||
Ok(())
|
||||
}
|
||||
hive_sh4re::CancelLooseEndKind::Approval => {
|
||||
// Manager-only: only the agent that can submit approvals
|
||||
// is allowed to withdraw them. Sub-agents would have no
|
||||
// pending approvals of their own to cancel anyway.
|
||||
check_approval_canceller_is_manager(canceller)?;
|
||||
let approval = coord
|
||||
.approvals
|
||||
.mark_cancelled(id, canceller)
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
tracing::info!(%id, %canceller, agent = %approval.agent, "approval cancelled");
|
||||
let sha_short = approval
|
||||
.fetched_sha
|
||||
.as_deref()
|
||||
.map(|s| s[..s.len().min(12)].to_owned());
|
||||
coord.emit_approval_resolved(
|
||||
approval.id,
|
||||
&approval.agent,
|
||||
kind_to_str(approval.kind),
|
||||
sha_short,
|
||||
"cancelled",
|
||||
approval.note,
|
||||
approval.description,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manager-only guard on the `Approval` cancel arm. Pulled out so
|
||||
/// the auth check has its own focused unit test (argus nit on #508)
|
||||
/// — testing the full `handle_cancel_loose_end` flow would need a
|
||||
/// `Coordinator` fixture (broker + sqlite + in-memory questions),
|
||||
/// which we don't have today. The check is a single string compare,
|
||||
/// so a function-level test gives the same coverage with no harness.
|
||||
fn check_approval_canceller_is_manager(canceller: &str) -> Result<(), String> {
|
||||
if canceller != hive_sh4re::MANAGER_AGENT {
|
||||
return Err("cancel_loose_end: only the manager can cancel approval rows".to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn approval_cancel_rejects_sub_agent_callers() {
|
||||
// Argus nit on #508: sub-agents must not be able to cancel
|
||||
// approval rows even if they invent an id. The guard is
|
||||
// server-side so client cooperation is irrelevant.
|
||||
let err = check_approval_canceller_is_manager("bitburner").unwrap_err();
|
||||
assert!(err.contains("only the manager"), "{err}");
|
||||
// Bonus: empty / operator strings also rejected (only the
|
||||
// exact MANAGER_AGENT constant passes).
|
||||
assert!(check_approval_canceller_is_manager("").is_err());
|
||||
assert!(
|
||||
check_approval_canceller_is_manager(hive_sh4re::OPERATOR_RECIPIENT)
|
||||
.is_err(),
|
||||
"operator surface uses the dashboard cancel path, not this dispatcher",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_cancel_allows_manager() {
|
||||
check_approval_canceller_is_manager(hive_sh4re::MANAGER_AGENT)
|
||||
.expect("MANAGER_AGENT must pass the guard");
|
||||
}
|
||||
}
|
||||
|
||||
// Real coverage needs a `Coordinator` fixture (broker + sqlite +
|
||||
// in-memory questions). Skipped for now — the normalisation branches
|
||||
// in `handle_ask` are short enough to read line-by-line; once we add
|
||||
// a coord test harness, drop integration tests here for: self-target
|
||||
// rejection, operator-string passthrough, agent-to-agent QuestionAsked
|
||||
// emission, and `Answer` authorisation.
|
||||
1164
hive-c0re/src/rebuild_queue.rs
Normal file
1164
hive-c0re/src/rebuild_queue.rs
Normal file
File diff suppressed because it is too large
Load diff
304
hive-c0re/src/reminder_scheduler.rs
Normal file
304
hive-c0re/src/reminder_scheduler.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
//! Background loop that drains due reminders out of the broker and
|
||||
//! delivers them as inbox messages. Mirrors the `events_vacuum` /
|
||||
//! `crash_watch` shape — a single `spawn(coord)` entry point started
|
||||
//! from `main.rs`.
|
||||
//!
|
||||
//! File-path semantics: a reminder may carry a `file_path` (the
|
||||
//! agent-visible path inside its container). On delivery we:
|
||||
//!
|
||||
//! - Translate the container path (`/agents/<agent>/state/foo.md`) to
|
||||
//! the host path (`/var/lib/hyperhive/agents/<agent>/state/foo.md`)
|
||||
//! so hive-c0re can write to it from outside the container.
|
||||
//! - Reject anything that isn't under the agent's own state subtree,
|
||||
//! contains `..` (path traversal), or has an empty relative tail.
|
||||
//! Falling outside the allowed prefix means the file write is
|
||||
//! skipped and the original message is delivered inline (with a
|
||||
//! noted warning) — the reminder still fires, just without the
|
||||
//! payload split.
|
||||
//! - Defend against symlink escape: after `create_dir_all`, the
|
||||
//! parent dir is canonicalized and re-verified to live under the
|
||||
//! agent's host state root. Then we open the final file with
|
||||
//! `O_NOFOLLOW | O_CREAT | O_TRUNC` so an existing-symlink basename
|
||||
//! can't redirect the write either. Without this an agent could
|
||||
//! `ln -s /etc /agents/foo/state/escape` and bounce a write to an
|
||||
//! arbitrary host path.
|
||||
//! - Write the reminder body to disk and deliver a short pointer
|
||||
//! message in its place, so the agent's inbox/wake-prompt stays
|
||||
//! small and the bulky payload can be read out of band.
|
||||
//!
|
||||
//! Atomicity of the inbox INSERT + `reminders.sent_at` UPDATE is handled
|
||||
//! inside `Broker::deliver_reminders_batch`; this module only computes the
|
||||
//! body strings before calling it.
|
||||
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
/// Per-tick cap on reminders delivered. Anything over this stays due
|
||||
/// in the table and gets picked up on the next tick — keeps a
|
||||
/// 10k-deep backlog from flooding the broker (or hogging the broker
|
||||
/// mutex) in one shot. 100/tick × 5s tick = sustained throughput cap
|
||||
/// of ~20 reminders/sec; bump together if the loose-ends tracker
|
||||
/// starts firing higher rates.
|
||||
const REMINDER_BATCH_LIMIT: u64 = 100;
|
||||
|
||||
/// Poll interval. Trade-off between latency on a freshly due reminder
|
||||
/// and CPU spent on empty sweeps; 5s matches the original inline
|
||||
/// scheduler.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
pub fn spawn(coord: Arc<Coordinator>) {
|
||||
let mut shutdown = coord.shutdown_rx();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tick(&coord);
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(POLL_INTERVAL) => {}
|
||||
_ = shutdown.changed() => {
|
||||
tracing::info!("reminder scheduler: shutdown signal received");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn tick(coord: &Arc<Coordinator>) {
|
||||
let due = match coord.broker.get_due_reminders(REMINDER_BATCH_LIMIT) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "failed to query due reminders");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if due.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Resolve body strings (file-path writes / inline) before entering
|
||||
// the batch transaction so the DB lock is held as briefly as possible.
|
||||
let items: Vec<(i64, String, String)> = due
|
||||
.iter()
|
||||
.map(|(agent, id, message, file_path)| {
|
||||
let body = prepare_body(agent, message, file_path.as_deref());
|
||||
(*id, agent.clone(), body)
|
||||
})
|
||||
.collect();
|
||||
// Single-transaction batch: one DB lock acquisition for N reminders
|
||||
// instead of N sequential lock/unlock cycles.
|
||||
let results = coord.broker.deliver_reminders_batch(&items);
|
||||
for ((id, agent, _body), result) in items.iter().zip(results.iter()) {
|
||||
if let Err(e) = result {
|
||||
let reason = format!("{e:#}");
|
||||
tracing::warn!(
|
||||
reminder_id = id,
|
||||
%agent,
|
||||
error = %reason,
|
||||
"failed to deliver reminder"
|
||||
);
|
||||
// Persist the failure so the dashboard can surface it.
|
||||
if let Err(persist_err) = coord.broker.record_reminder_failure(*id, &reason) {
|
||||
tracing::warn!(
|
||||
reminder_id = id,
|
||||
error = ?persist_err,
|
||||
"failed to persist reminder failure"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the inbox body for a due reminder. When `file_path` is None
|
||||
/// the body is the original message verbatim. When set, we attempt to
|
||||
/// persist the message body to the requested file and return a short
|
||||
/// pointer string instead. Failures (bad prefix, symlink escape,
|
||||
/// write error, missing parent) fall back to inline delivery with a
|
||||
/// noted warning so the reminder still fires.
|
||||
fn prepare_body(agent: &str, message: &str, file_path: Option<&str>) -> String {
|
||||
let Some(req_path) = file_path else {
|
||||
return message.to_owned();
|
||||
};
|
||||
let host_path = match resolve_host_path(agent, req_path) {
|
||||
Ok(p) => p,
|
||||
Err(reason) => {
|
||||
tracing::warn!(%agent, %req_path, %reason, "reminder file_path rejected; delivering inline");
|
||||
return inline_fallback(req_path, &format!("rejected: {reason}"), message);
|
||||
}
|
||||
};
|
||||
match write_payload(agent, &host_path, message) {
|
||||
Ok(()) => {
|
||||
let bytes = message.len();
|
||||
// debug! not info! — under load this would dominate the log.
|
||||
tracing::debug!(%agent, path = %host_path.display(), bytes, "reminder body written to file");
|
||||
format!(
|
||||
"reminder body persisted to `{req_path}` ({bytes} bytes); read with your filesystem tools"
|
||||
)
|
||||
}
|
||||
Err(reason) => {
|
||||
tracing::warn!(%agent, path = %host_path.display(), %reason, "reminder file_path write failed; delivering inline");
|
||||
inline_fallback(req_path, &reason, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_fallback(req_path: &str, reason: &str, message: &str) -> String {
|
||||
format!("[reminder file_path '{req_path}' {reason}; delivering body inline]\n\n{message}")
|
||||
}
|
||||
|
||||
/// Persist `message` to `host_path` with the symlink-escape defenses
|
||||
/// described in the module docs. Returns `Ok(())` on success, or a
|
||||
/// human-readable reason string on any failure (caller logs +
|
||||
/// inline-falls-back). `pub` because `agent_server::handle_remind`
|
||||
/// reuses it for the at-remind-time auto-file path.
|
||||
pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), String> {
|
||||
let Some(parent) = host_path.parent() else {
|
||||
return Err("internal: host path has no parent".to_owned());
|
||||
};
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("parent dir create failed: {e}"))?;
|
||||
// Resolve symlinks in the parent chain, then re-verify the
|
||||
// canonical form still lives under the agent's host state root —
|
||||
// catches `ln -s /etc state/escape` style attacks.
|
||||
let parent_canonical = parent
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("parent canonicalize failed: {e}"))?;
|
||||
let agent_root = Coordinator::agent_notes_dir(agent)
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("agent state root canonicalize failed: {e}"))?;
|
||||
if !parent_canonical.starts_with(&agent_root) {
|
||||
return Err(format!(
|
||||
"symlink escape: canonical parent `{}` outside agent root `{}`",
|
||||
parent_canonical.display(),
|
||||
agent_root.display()
|
||||
));
|
||||
}
|
||||
let basename = host_path
|
||||
.file_name()
|
||||
.ok_or_else(|| "missing basename".to_owned())?;
|
||||
let target = parent_canonical.join(basename);
|
||||
// O_NOFOLLOW on the final component refuses to open if the
|
||||
// basename is itself an existing symlink. Combined with the
|
||||
// canonicalize-parent check above, no symlink anywhere in the
|
||||
// path can redirect the write.
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(&target)
|
||||
.map_err(|e| format!("open failed: {e}"))?;
|
||||
file.write_all(message.as_bytes())
|
||||
.map_err(|e| format!("write failed: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Container-visible state prefix the caller's `file_path` must live
|
||||
/// under. Sub-agents see their state at `/agents/<name>/state/`;
|
||||
/// the manager keeps the legacy `/state/` mount (see
|
||||
/// `lifecycle::set_nspawn_flags`). Auto-file paths use the same
|
||||
/// prefix so the round-trip is symmetric.
|
||||
#[must_use]
|
||||
pub fn container_state_prefix(agent: &str) -> String {
|
||||
if agent == hive_sh4re::MANAGER_AGENT {
|
||||
"/state/".to_owned()
|
||||
} else {
|
||||
format!("/agents/{agent}/state/")
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an agent-visible container path to the matching host path,
|
||||
/// validating that it lives under the agent's own state subtree, has
|
||||
/// a non-empty relative tail, and doesn't try to traverse out via
|
||||
/// `..`. Returns the host `PathBuf` on success, or a human-readable
|
||||
/// reason string on rejection. `pub` so `agent_server::handle_remind`
|
||||
/// can reuse it for the at-remind-time auto-file path.
|
||||
pub fn resolve_host_path(agent: &str, req_path: &str) -> Result<PathBuf, String> {
|
||||
let prefix = container_state_prefix(agent);
|
||||
let Some(rel) = req_path.strip_prefix(&prefix) else {
|
||||
return Err(format!(
|
||||
"must be absolute and under `{prefix}` (got `{req_path}`)"
|
||||
));
|
||||
};
|
||||
if rel.is_empty() {
|
||||
return Err("file_path must include a filename, not just the state dir".to_owned());
|
||||
}
|
||||
let rel_path = Path::new(rel);
|
||||
for comp in rel_path.components() {
|
||||
match comp {
|
||||
std::path::Component::Normal(_) => {}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"path component `{other:?}` not allowed (no traversal / absolute / root)"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Coordinator::agent_notes_dir(agent).join(rel_path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_paths_outside_agent_state() {
|
||||
assert!(resolve_host_path("foo", "/etc/passwd").is_err());
|
||||
assert!(resolve_host_path("foo", "/agents/bar/state/x.md").is_err());
|
||||
assert!(resolve_host_path("foo", "relative.md").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal() {
|
||||
assert!(resolve_host_path("foo", "/agents/foo/state/../../etc/passwd").is_err());
|
||||
assert!(resolve_host_path("foo", "/agents/foo/state/./x.md").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_relative_tail() {
|
||||
// Trailing slash → empty tail. Used to fall through to
|
||||
// create_dir_all + write-to-dir → confusing inline fallback;
|
||||
// explicit reject gives a cleaner log.
|
||||
let err = resolve_host_path("foo", "/agents/foo/state/").unwrap_err();
|
||||
assert!(err.contains("must include a filename"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_well_formed_path() {
|
||||
let p = resolve_host_path("foo", "/agents/foo/state/reminders/123.md").unwrap();
|
||||
assert_eq!(
|
||||
p,
|
||||
PathBuf::from("/var/lib/hyperhive/agents/foo/state/reminders/123.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_uses_legacy_state_prefix() {
|
||||
// The manager container mounts its state at `/state/` (legacy),
|
||||
// not `/agents/manager/state/`. Same host path; different
|
||||
// container-visible path. resolve_host_path needs to know.
|
||||
assert_eq!(container_state_prefix("manager"), "/state/");
|
||||
let p = resolve_host_path("manager", "/state/reminders/x.md").unwrap();
|
||||
assert_eq!(
|
||||
p,
|
||||
PathBuf::from("/var/lib/hyperhive/agents/manager/state/reminders/x.md")
|
||||
);
|
||||
// And the sub-agent prefix must NOT be accepted for the manager.
|
||||
assert!(resolve_host_path("manager", "/agents/manager/state/x.md").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_body_passthrough_when_no_file_path() {
|
||||
let s = prepare_body("foo", "hello world", None);
|
||||
assert_eq!(s, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_body_falls_back_inline_on_bad_path() {
|
||||
let s = prepare_body("foo", "payload", Some("/etc/passwd"));
|
||||
assert!(s.starts_with("[reminder file_path '/etc/passwd' rejected:"));
|
||||
assert!(s.contains("payload"));
|
||||
}
|
||||
}
|
||||
1072
hive-c0re/src/scheduled_prompts.rs
Normal file
1072
hive-c0re/src/scheduled_prompts.rs
Normal file
File diff suppressed because it is too large
Load diff
391
hive-c0re/src/scheduled_prompts_worker.rs
Normal file
391
hive-c0re/src/scheduled_prompts_worker.rs
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
//! Background loop that drains due `scheduled_prompts` rows
|
||||
//! (#444) and fans the body out as inbox `Message`s to each
|
||||
//! active target. Mirrors `reminder_scheduler::spawn` shape:
|
||||
//! single `spawn(coord)` entry, 5s poll cadence, shutdown-aware.
|
||||
//!
|
||||
//! ## Catch-up semantics
|
||||
//!
|
||||
//! When hive-c0re comes back from being down, a recurring row
|
||||
//! whose `next_fire_at` is well in the past would otherwise fire
|
||||
//! N delayed pulses in a row. Instead we fire ONCE and let
|
||||
//! `ScheduledPrompts::rearm` bump `next_fire_at` to the next
|
||||
//! interval slot ≥ `now`, recording the skipped-cycle count in
|
||||
//! the per-target `last_result` so operators see how many
|
||||
//! firings were caught up rather than losing the signal.
|
||||
//!
|
||||
//! ## Missing-target failure
|
||||
//!
|
||||
//! When a target name doesn't resolve to a known agent (the
|
||||
//! container has been destroyed, the operator typo'd a name,
|
||||
//! etc.) the worker:
|
||||
//! 1. records `last_result = "no such agent: <name>"` against
|
||||
//! the per-target row,
|
||||
//! 2. sends a single advisory `Message` from `system` to
|
||||
//! `operator` describing the schedule + target + reason,
|
||||
//! 3. continues fanning out to the other (live) targets.
|
||||
//!
|
||||
//! Transient broker errors (sqlite lock contention, etc.) get
|
||||
//! the per-target `last_result` annotated AND a `tracing::warn`,
|
||||
//! but the post-fire bookkeeping treats the row the same way it
|
||||
//! does on a clean fire:
|
||||
//! - **recurring** rows re-arm — the next interval slot tries
|
||||
//! the broker send again, so transient errors self-heal.
|
||||
//! - **one-shots** delete unconditionally after their single
|
||||
//! fan-out pass; a broker failure on a one-shot is NOT
|
||||
//! retried (the operator advisory + last_result are the only
|
||||
//! audit trail).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use hive_sh4re::Message;
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::scheduled_prompts::Schedule;
|
||||
|
||||
/// Per-tick cap. Each schedule fires once per tick at most;
|
||||
/// 100/tick × 5s tick = sustained throughput cap of ~20/sec,
|
||||
/// matching `reminder_scheduler::REMINDER_BATCH_LIMIT`. Bump
|
||||
/// together if real-world rates push past this.
|
||||
const SCHEDULE_BATCH_LIMIT: u64 = 100;
|
||||
|
||||
/// Poll interval. Same 5s as the reminder scheduler — picking
|
||||
/// up freshly-due rows within at most one tick keeps the
|
||||
/// dashboard's "next fire in ..." countdown honest without
|
||||
/// burning CPU on empty sweeps.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Reap cancelled schedules older than this from the table so
|
||||
/// the dashboard list view doesn't accrue tombstones forever.
|
||||
/// Cancelled rows live long enough that the operator can still
|
||||
/// see what they cancelled in the recent past.
|
||||
const CANCELLED_REAP_AGE: Duration = Duration::from_secs(3600);
|
||||
|
||||
pub fn spawn(coord: Arc<Coordinator>) {
|
||||
let mut shutdown = coord.shutdown_rx();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tick(&coord);
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(POLL_INTERVAL) => {}
|
||||
_ = shutdown.changed() => {
|
||||
tracing::info!("scheduled_prompts worker: shutdown signal received");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn tick(coord: &Arc<Coordinator>) {
|
||||
let now = now_unix();
|
||||
let due = match coord.scheduled_prompts.due(now, SCHEDULE_BATCH_LIMIT) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "scheduled_prompts: query due rows failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if due.is_empty() {
|
||||
// Periodic reaper still gets a chance even on empty ticks.
|
||||
let cutoff = now - i64::try_from(CANCELLED_REAP_AGE.as_secs()).unwrap_or(0);
|
||||
if let Err(e) = coord.scheduled_prompts.reap_cancelled(cutoff) {
|
||||
tracing::warn!(error = ?e, "scheduled_prompts: reap cancelled failed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
for schedule in due {
|
||||
fire_schedule(coord, &schedule, now);
|
||||
}
|
||||
let cutoff = now - i64::try_from(CANCELLED_REAP_AGE.as_secs()).unwrap_or(0);
|
||||
if let Err(e) = coord.scheduled_prompts.reap_cancelled(cutoff) {
|
||||
tracing::warn!(error = ?e, "scheduled_prompts: reap cancelled failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Fan out one schedule's body to every active target. Records
|
||||
/// per-target last_result; advances or reaps the parent row at
|
||||
/// the end depending on whether `interval_seconds` is set.
|
||||
fn fire_schedule(coord: &Arc<Coordinator>, schedule: &Schedule, now: i64) {
|
||||
let known: std::collections::HashSet<String> = known_agents(coord);
|
||||
for target_row in &schedule.targets {
|
||||
if target_row.cancelled_at_unix.is_some() {
|
||||
continue;
|
||||
}
|
||||
let target = &target_row.target;
|
||||
// `operator` is a valid recipient (mara c4) — operator
|
||||
// delivery uses the regular broker path; the dashboard
|
||||
// mirrors `to == operator` into its own pane.
|
||||
if target != hive_sh4re::OPERATOR_RECIPIENT && !known.contains(target) {
|
||||
let reason = format!("no such agent: {target}");
|
||||
if let Err(e) = coord.scheduled_prompts.record_target_result(
|
||||
schedule.id,
|
||||
target,
|
||||
now,
|
||||
&reason,
|
||||
) {
|
||||
tracing::warn!(error = ?e, schedule = schedule.id, %target, "record_target_result failed");
|
||||
}
|
||||
notify_operator_missing_target(coord, schedule, target);
|
||||
continue;
|
||||
}
|
||||
let msg = Message {
|
||||
from: "scheduled".to_owned(),
|
||||
to: target.clone(),
|
||||
body: schedule.body.clone(),
|
||||
in_reply_to: None,
|
||||
};
|
||||
let result = coord.broker.send(&msg);
|
||||
let result_str = match &result {
|
||||
Ok(()) => "ok".to_owned(),
|
||||
Err(e) => format!("broker send failed: {e:#}"),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(
|
||||
schedule = schedule.id,
|
||||
%target,
|
||||
error = ?e,
|
||||
"scheduled_prompts: broker send failed (will retry on next interval)"
|
||||
);
|
||||
}
|
||||
if let Err(e) =
|
||||
coord
|
||||
.scheduled_prompts
|
||||
.record_target_result(schedule.id, target, now, &result_str)
|
||||
{
|
||||
tracing::warn!(error = ?e, schedule = schedule.id, %target, "record_target_result failed");
|
||||
}
|
||||
}
|
||||
// Advance or reap. One-shots delete; recurring re-arm with
|
||||
// catch-up clamp.
|
||||
if schedule.interval_seconds.is_some() {
|
||||
match coord.scheduled_prompts.rearm(schedule.id, now) {
|
||||
Ok(0) => {}
|
||||
Ok(skipped) => {
|
||||
tracing::info!(
|
||||
schedule = schedule.id,
|
||||
skipped,
|
||||
"scheduled_prompts: caught up missed cycles"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, schedule = schedule.id, "rearm failed");
|
||||
}
|
||||
}
|
||||
} else if let Err(e) = coord.scheduled_prompts.delete(schedule.id) {
|
||||
tracing::warn!(error = ?e, schedule = schedule.id, "delete one-shot failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of live container names for the missing-target check.
|
||||
/// Always seeds the manager name (which is always reachable);
|
||||
/// adds every live nspawn container that matches the `h-` prefix.
|
||||
/// On `lifecycle::list` failure the set stays at just the manager
|
||||
/// — fail-CLOSED, meaning every non-operator/non-manager target
|
||||
/// looks missing this tick and gets the same treatment as a
|
||||
/// genuinely-destroyed agent: operator advisory + per-target
|
||||
/// `last_result` annotation + skipped delivery. Recurring
|
||||
/// schedules recover automatically on the next tick (the lifecycle
|
||||
/// listing usually works); one-shots that land on this window
|
||||
/// lose their single delivery. Logged at `warn`, not propagated.
|
||||
fn known_agents(_coord: &Coordinator) -> std::collections::HashSet<String> {
|
||||
// `lifecycle::list` is async; the worker tick is sync. Use the
|
||||
// blocking variant via a small `tokio::runtime::Handle::block_on`
|
||||
// wrapper. The worker runs in its own tokio task so this is
|
||||
// safe (we're not in a `current_thread` runtime).
|
||||
use std::collections::HashSet;
|
||||
let mut out: HashSet<String> = HashSet::new();
|
||||
out.insert(hive_sh4re::MANAGER_AGENT.to_owned());
|
||||
let containers = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(crate::lifecycle::list())
|
||||
});
|
||||
match containers {
|
||||
Ok(list) => {
|
||||
for raw in list {
|
||||
if let Some(name) = raw.strip_prefix(crate::lifecycle::AGENT_PREFIX) {
|
||||
out.insert(name.to_owned());
|
||||
} else if raw == crate::lifecycle::MANAGER_NAME {
|
||||
out.insert(hive_sh4re::MANAGER_AGENT.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "scheduled_prompts: container listing failed");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Send the operator a one-line advisory when a schedule fires
|
||||
/// against an agent that no longer exists. Best-effort — failure
|
||||
/// to send just gets logged; the schedule continues firing.
|
||||
fn notify_operator_missing_target(coord: &Coordinator, schedule: &Schedule, target: &str) {
|
||||
let body = format!(
|
||||
"scheduled prompt #{id} fired but target `{target}` is not a live agent. \
|
||||
body was:\n\n{body}",
|
||||
id = schedule.id,
|
||||
target = target,
|
||||
body = schedule.body
|
||||
);
|
||||
let msg = Message {
|
||||
from: "scheduled".to_owned(),
|
||||
to: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
||||
body,
|
||||
in_reply_to: None,
|
||||
};
|
||||
if let Err(e) = coord.broker.send(&msg) {
|
||||
tracing::warn!(error = ?e, schedule = schedule.id, %target, "operator advisory send failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Per-target outcome counts for one `fire_now` invocation.
|
||||
/// Returned to the operator so the dashboard can render
|
||||
/// "fired to N (M failed, K missing)" without a follow-up GET.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct FireNowReport {
|
||||
/// Targets the broker accepted the message for.
|
||||
pub ok: u32,
|
||||
/// Targets where broker.send returned an error.
|
||||
pub failed: u32,
|
||||
/// Targets that didn't resolve to a known agent (and got the
|
||||
/// operator-advisory treatment).
|
||||
pub missing: u32,
|
||||
/// Whether the one-shot was consumed by this manual fire.
|
||||
/// `true` only when the schedule was a one-shot (recurring
|
||||
/// schedules never auto-cancel on manual fire — they keep
|
||||
/// their cadence).
|
||||
pub one_shot_consumed: bool,
|
||||
}
|
||||
|
||||
/// Manual / out-of-band fire of a scheduled prompt (#467 "fire
|
||||
/// now" button). Mirrors the per-target fan-out of `fire_schedule`
|
||||
/// but skips the rearm step entirely — manual fires don't disturb
|
||||
/// a recurring schedule's rhythm. For one-shots, a manual fire
|
||||
/// **consumes** the schedule (operator intent: "send this now,
|
||||
/// the scheduled time was wrong"); recurring schedules keep their
|
||||
/// `next_fire_at_unix` unchanged.
|
||||
///
|
||||
/// `last_result` is annotated with the `manual fire:` prefix so
|
||||
/// the dashboard's per-target last-result column can distinguish
|
||||
/// scheduled fires from operator-initiated ones at a glance.
|
||||
///
|
||||
/// Returns Err if the schedule is missing, cancelled, or fully
|
||||
/// drained of active targets — the dashboard can surface those
|
||||
/// as plain 4xxs instead of pretending to fire a phantom row.
|
||||
pub async fn fire_now(
|
||||
coord: &std::sync::Arc<Coordinator>,
|
||||
schedule_id: i64,
|
||||
) -> anyhow::Result<FireNowReport> {
|
||||
let now = now_unix();
|
||||
let schedule = coord
|
||||
.scheduled_prompts
|
||||
.get(schedule_id)?
|
||||
.ok_or_else(|| anyhow::anyhow!("schedule {schedule_id} not found"))?;
|
||||
if schedule.cancelled_at_unix.is_some() {
|
||||
anyhow::bail!("schedule {schedule_id} is already cancelled");
|
||||
}
|
||||
if !schedule.targets.iter().any(|t| t.cancelled_at_unix.is_none()) {
|
||||
anyhow::bail!("schedule {schedule_id} has no active targets");
|
||||
}
|
||||
let known = known_agents_async().await;
|
||||
let mut report = FireNowReport {
|
||||
ok: 0,
|
||||
failed: 0,
|
||||
missing: 0,
|
||||
one_shot_consumed: false,
|
||||
};
|
||||
for target_row in &schedule.targets {
|
||||
if target_row.cancelled_at_unix.is_some() {
|
||||
continue;
|
||||
}
|
||||
let target = &target_row.target;
|
||||
if target != hive_sh4re::OPERATOR_RECIPIENT && !known.contains(target) {
|
||||
let reason = format!("manual fire: no such agent: {target}");
|
||||
if let Err(e) = coord.scheduled_prompts.record_target_result(
|
||||
schedule_id,
|
||||
target,
|
||||
now,
|
||||
&reason,
|
||||
) {
|
||||
tracing::warn!(error = ?e, schedule = schedule_id, %target, "record_target_result failed");
|
||||
}
|
||||
notify_operator_missing_target(coord, &schedule, target);
|
||||
report.missing += 1;
|
||||
continue;
|
||||
}
|
||||
let msg = Message {
|
||||
from: "scheduled".to_owned(),
|
||||
to: target.clone(),
|
||||
body: schedule.body.clone(),
|
||||
in_reply_to: None,
|
||||
};
|
||||
let result = coord.broker.send(&msg);
|
||||
let result_str = match &result {
|
||||
Ok(()) => "manual fire: ok".to_owned(),
|
||||
Err(e) => format!("manual fire: broker send failed: {e:#}"),
|
||||
};
|
||||
if result.is_ok() {
|
||||
report.ok += 1;
|
||||
} else {
|
||||
report.failed += 1;
|
||||
tracing::warn!(
|
||||
schedule = schedule_id,
|
||||
%target,
|
||||
error = ?result.as_ref().err(),
|
||||
"fire_now: broker send failed (no retry — manual fires don't loop)"
|
||||
);
|
||||
}
|
||||
if let Err(e) =
|
||||
coord
|
||||
.scheduled_prompts
|
||||
.record_target_result(schedule_id, target, now, &result_str)
|
||||
{
|
||||
tracing::warn!(error = ?e, schedule = schedule_id, %target, "record_target_result failed");
|
||||
}
|
||||
}
|
||||
if schedule.interval_seconds.is_none() {
|
||||
// One-shot is consumed by the manual fire. Recurring
|
||||
// schedules stay untouched — their cadence is the whole
|
||||
// point and a manual fire is meant to be additive.
|
||||
if let Err(e) = coord.scheduled_prompts.cancel_all(schedule_id) {
|
||||
tracing::warn!(error = ?e, schedule = schedule_id, "cancel_all after one-shot manual fire failed");
|
||||
} else {
|
||||
report.one_shot_consumed = true;
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Async variant of `known_agents` for `fire_now`. Same logic +
|
||||
/// same fail-closed degradation; the difference is just that the
|
||||
/// dashboard handler is genuinely async so we `await` the
|
||||
/// `lifecycle::list` directly instead of going through the
|
||||
/// `block_in_place` shim.
|
||||
async fn known_agents_async() -> std::collections::HashSet<String> {
|
||||
use std::collections::HashSet;
|
||||
let mut out: HashSet<String> = HashSet::new();
|
||||
out.insert(hive_sh4re::MANAGER_AGENT.to_owned());
|
||||
match crate::lifecycle::list().await {
|
||||
Ok(list) => {
|
||||
for raw in list {
|
||||
if let Some(name) = raw.strip_prefix(crate::lifecycle::AGENT_PREFIX) {
|
||||
out.insert(name.to_owned());
|
||||
} else if raw == crate::lifecycle::MANAGER_NAME {
|
||||
out.insert(hive_sh4re::MANAGER_AGENT.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "fire_now: container listing failed");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
206
hive-c0re/src/server.rs
Normal file
206
hive-c0re/src/server.rs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use hive_sh4re::{HostRequest, HostResponse};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
|
||||
use crate::actions;
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::lifecycle;
|
||||
|
||||
pub async fn serve(socket: &Path, coord: Arc<Coordinator>) -> Result<()> {
|
||||
if let Some(parent) = socket.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create socket parent {}", parent.display()))?;
|
||||
}
|
||||
if socket.exists() {
|
||||
std::fs::remove_file(socket).context("remove stale socket")?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(socket)
|
||||
.with_context(|| format!("bind admin socket {}", socket.display()))?;
|
||||
tracing::info!(socket = %socket.display(), hyperhive_flake = %coord.hyperhive_flake, "hive-c0re admin listening");
|
||||
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await.context("accept connection")?;
|
||||
let coord = coord.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle(stream, coord).await {
|
||||
tracing::warn!(error = ?e, "connection failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
|
||||
let (read, mut write) = stream.into_split();
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let resp = match serde_json::from_str::<HostRequest>(line.trim()) {
|
||||
Ok(req) => dispatch(&req, coord.clone()).await,
|
||||
Err(e) => HostResponse::error(format!("parse error: {e}")),
|
||||
};
|
||||
let mut payload = serde_json::to_string(&resp)?;
|
||||
payload.push('\n');
|
||||
write.write_all(payload.as_bytes()).await?;
|
||||
write.flush().await?;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||
let result: anyhow::Result<HostResponse> = async {
|
||||
Ok(match req {
|
||||
HostRequest::Spawn { name } => {
|
||||
tracing::info!(%name, "spawn");
|
||||
let agent_dir = coord.ensure_runtime(name)?;
|
||||
let proposed_dir = Coordinator::agent_proposed_dir(name);
|
||||
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||
let claude_dir = Coordinator::agent_claude_dir(name);
|
||||
let notes_dir = Coordinator::agent_notes_dir(name);
|
||||
match lifecycle::spawn(
|
||||
name,
|
||||
&coord.hyperhive_flake,
|
||||
&agent_dir,
|
||||
&proposed_dir,
|
||||
&applied_dir,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
&coord.operator_pronouns,
|
||||
&coord.context_window_tokens,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
coord.notify_manager(&hive_sh4re::HelperEvent::Spawned {
|
||||
agent: name.clone(),
|
||||
ok: true,
|
||||
note: None,
|
||||
sha: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// Roll back socket registration if container creation failed.
|
||||
coord.unregister_agent(name);
|
||||
coord.notify_manager(&hive_sh4re::HelperEvent::Spawned {
|
||||
agent: name.clone(),
|
||||
ok: false,
|
||||
note: Some(format!("{e:#}")),
|
||||
sha: None,
|
||||
});
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::RequestSpawn { name } => {
|
||||
tracing::info!(%name, "request_spawn");
|
||||
let id =
|
||||
coord
|
||||
.approvals
|
||||
.submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "", None)?;
|
||||
tracing::info!(%id, %name, "spawn approval queued");
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::Kill { name } => {
|
||||
tracing::info!(%name, "kill");
|
||||
lifecycle::kill(name).await?;
|
||||
coord.unregister_agent(name);
|
||||
coord.notify_manager(&hive_sh4re::HelperEvent::Killed {
|
||||
agent: name.clone(),
|
||||
});
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::Destroy { name, purge } => {
|
||||
actions::destroy(&coord, name, *purge).await?;
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::Rebuild { name } => {
|
||||
tracing::info!(%name, "rebuild");
|
||||
let agent_dir = coord.ensure_runtime(name)?;
|
||||
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||
let claude_dir = Coordinator::agent_claude_dir(name);
|
||||
let notes_dir = Coordinator::agent_notes_dir(name);
|
||||
let result = lifecycle::rebuild(
|
||||
name,
|
||||
&coord.hyperhive_flake,
|
||||
&agent_dir,
|
||||
&applied_dir,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
&coord.operator_pronouns,
|
||||
&coord.context_window_tokens,
|
||||
)
|
||||
.await;
|
||||
// Mirror auto_update::rebuild_agent — the manager wants
|
||||
// to know about every rebuild attempt regardless of
|
||||
// which surface triggered it, especially failures
|
||||
// (build error → manager can adjust the agent's
|
||||
// agent.nix). Without this the admin-socket CLI was
|
||||
// a notify-gap.
|
||||
match &result {
|
||||
Ok(()) => {
|
||||
coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
|
||||
agent: name.clone(),
|
||||
ok: true,
|
||||
note: None,
|
||||
sha: None,
|
||||
tag: None,
|
||||
});
|
||||
// Wake the agent's next turn with the
|
||||
// "you were rebuilt" hint. Same pattern as
|
||||
// auto_update::rebuild_agent and the dashboard
|
||||
// rebuild path — this is the CLI's equivalent.
|
||||
coord.kick_agent(name, "container rebuilt");
|
||||
}
|
||||
Err(e) => coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
|
||||
agent: name.clone(),
|
||||
ok: false,
|
||||
note: Some(format!("{e:#}")),
|
||||
sha: None,
|
||||
tag: None,
|
||||
}),
|
||||
}
|
||||
result?;
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::List => HostResponse::list(lifecycle::list().await?),
|
||||
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
|
||||
HostRequest::Approve { id } => {
|
||||
actions::approve(coord.clone(), *id).await?;
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::Deny { id } => {
|
||||
actions::deny(&coord, *id, None).await?;
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::SetParent { child, new_parent } => {
|
||||
tracing::info!(%child, ?new_parent, "set_parent");
|
||||
crate::topology::set_parent(child, new_parent.as_deref())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
// ContainerView.parent is read from topology.json — a
|
||||
// change here means every container row potentially
|
||||
// moves in the dashboard tree. Rescan + diff-emit so
|
||||
// open viewers repaint without polling.
|
||||
coord.rescan_containers_and_emit().await;
|
||||
HostResponse::success()
|
||||
}
|
||||
})
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => HostResponse::error(format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue