hyperhive/hive-forge-notify/src/lib.rs
atlas 0db83c40a0 feat(#2642): a github.com notification poller alongside the forge one
hive-forge-notify grows a second binary, hive-github-notify. The two
share the notification half of the job — tolerant parse, classification,
formatting, dedupe, todo delivery — and nothing else: each binary owns
its host's protocol outright.

Two binaries rather than one multi-source daemon, and rather than a
cargo feature. A feature would unify across the workspace and cost every
crate its build cache. Two binaries keep the decision in nix: forge.nix
installs the forge unit, github.nix installs the github one under
hyperhive.github.enable, so a hive built without that module has no
github poller in its closure at all — GitHub access is separable (a
tier, a policy boundary), not merely switched off. Both binaries ship
from the existing derivation, so packages.nix is untouched.

The split is real at the code level too, not just at the unit level.
source.rs is a trait; the impls live in the binaries that use them, so
neither binary links the other's protocol code and the library names no
host at all. The forge-only assigned-issue rollup moves into the forge
binary for the same reason: it asks the forge what is assigned to this
agent, which is not a notification-protocol concern.

At runtime the github unit needs a PAT at <state>/github-token, the same
dashboard-provisioned token the gh wrapper and the git credential helper
already use. No PAT: it logs why and exits 0, which is why the unit is
Restart=on-failure and not always.

Forgejo's notifications API is modelled on GitHub's, so one tolerant
parse serves both — the differences (string thread ids, PullRequest vs
Pull) are absorbed by lenient deserializers rather than a second parse
path. Thread ids normalise to String at the parse boundary; they are
only ever opaque keys. Todo keys gain a per-source prefix so the two
hosts cannot collide, and the forge's is deliberately empty to keep
existing forge todo keys stable across the deploy that lands this.

The github loop honours the server's X-Poll-Interval, re-arming only
when the server asks for a slower cadence than ours; the hint is read
before the status check, because it arrives on error and empty pages too
and that is exactly when it matters. Reading the notification stream
needs the notifications scope on the PAT, which a token minted for push
access typically lacks; the failure mode is silence, so docs/github.md
says so explicitly.
2026-07-31 17:23:18 +02:00

68 lines
3.1 KiB
Rust

//! Per-agent notification pollers — shared library half.
//!
//! Two binaries ship from this crate, one per notification host:
//!
//! - **`hive-forge-notify`** — the hive's internal Forgejo. Always
//! deployed; the behaviour predates this split and is unchanged.
//! - **`hive-github-notify`** — github.com, for agents that have a PAT.
//!
//! They are separate *binaries* rather than one process with two loops so
//! the deployment can choose: an agent module installs the GitHub unit or
//! it doesn't, and the decision lives in the module rather than in a cargo
//! feature. A feature flag would unify across the workspace — enabling it
//! for one consumer changes feature resolution for the whole graph and
//! stops the two builds sharing any cached crate — which is a permanent
//! cost for something a second binary expresses for free.
//!
//! Everything except the host-specific calls (list unread, mark read,
//! resolve own login) is shared and lives here: classification, wake
//! formatting, the tolerant parse, delivery dedupe, and the todo upsert.
//! The host differences live behind [`source::Source`].
pub mod notify;
pub mod source;
/// Re-exported so a binary can spell it once at the crate root alongside
/// the other things it needs to build a client; it is a property of the
/// poller, not of the notification format.
pub use notify::HTTP_TIMEOUT_SECS;
/// Retry policy for the harness's in-agent socket. Deliberately fail-fast:
/// both callers are inside the poll loop and both treat a failed request as
/// "leave the thread unread and try again next tick", so the poll interval
/// *is* the retry — a second, in-request backoff would only stack sleeps on
/// top of it and delay the rest of the batch. That is the opposite
/// trade-off from the serve loop's client, which rides out a hive-c0re
/// restart because its callers have no natural retry of their own.
pub const TODO_SOCKET_RETRY: hive_sock_client::Retry = hive_sock_client::Retry::None;
/// Resolve the harness's in-agent todo socket from the environment, falling
/// back to the well-known path. Shared by both binaries so they can't drift
/// on where they deliver.
#[must_use]
pub fn agent_socket() -> std::path::PathBuf {
std::env::var_os("HIVE_AGENT_SOCKET").map_or_else(
|| std::path::PathBuf::from(hive_agent_sock::DEFAULT_AGENT_SOCKET),
std::path::PathBuf::from,
)
}
/// Install the tracing subscriber both binaries use: `RUST_LOG` when set,
/// `info` otherwise.
pub fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_env("RUST_LOG")
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
}
/// Read `HYPERHIVE_STATE_DIR`, the directory holding the agent's
/// credentials (`forge-token`, `github-token`). Empty when unset, which
/// makes the token paths relative and the read fail — the callers treat
/// that as "not configured" and settle.
#[must_use]
pub fn state_dir() -> String {
std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default()
}