Compare commits

..
19 changed files with 127 additions and 357 deletions

View file

@ -129,16 +129,16 @@ best-effort: logged at debug/warn and retried next tick.
Forgejo fires notifications for the agent's own actions (it opened a
PR, posted a comment, submitted a review). Surfacing those would
loop claude on its own writes. The comment/review case is dropped
silently (mark-read without delivery):
loop claude on its own writes. Two filter rules drop them silently
(mark-read without delivery):
- **Self-authored new items** — notifications with
`reason == "author"` AND subject state `open` (or missing). State
transitions (merge / close) on the agent's own PRs DO surface,
since those are triggered by someone else.
- **Self-authored comments / reviews** — comment payload's
`user.login` matches `own_login`.
Self-authored *new items* (an agent opening its own PR/issue) are not
filtered and do surface — the notification subject carries no author
field to match against without a per-notification fetch.
`own_login` is fetched once at startup via `GET /api/v1/user`. On
fetch failure the filter degrades open (no filtering) rather than
crashing the task — a noisy inbox beats a silently-stuck poller.
@ -181,11 +181,11 @@ Five shapes, distinguished by the notification's classification:
| Trigger | Wrapper |
| --- | --- |
| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` |
| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...` |
| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...` |
| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...` |
| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` |
| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...\nreason: mention` |
| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...\nreason: review_requested` |
| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...\nreason: subscribed` |
| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...\nreason: subscribed` |
| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...\nreason: subscribed` |
Review labels come from the Forgejo `state` field: `APPROVED`
`approved`, `REQUEST_CHANGES``changes requested`, `COMMENT`
@ -222,6 +222,14 @@ Every wrapper ends with one or more of:
the line shape is stable.
- `reviewer: <list>` — PR notifications only, present only when
`requested_reviewers` is non-empty.
- `reason: <forgejo-reason>` — always present when the notification
carries a reason; absent when the field is null/missing.
The `reason` line distinguishes otherwise-identical messages: Forgejo
emits one notification per applicable reason for the same event
(e.g. both `mention` and `subscribed` arrive for a PR comment that
tags the agent). Without the suffix, the agent would see duplicated
wrapper text with no signal which Forgejo path triggered each copy.
### Review-request override

View file

@ -19,7 +19,7 @@ under `issue` and `pr` parent commands — `hive-forge pr close 42`,
refuses an issue number, which the old generic `close` couldn't). Run
`hive-forge pr --help` / `hive-forge issue --help` for the full subcommand
list (show/create/edit/status/merge/reviews/commits/diff/view/comment/
comments/close/reopen/labels/assign/timeline as applicable).
comments/close/labels/assign/timeline as applicable).
The flat forms below (`close 42`, `pr-create …`, `pr-status …`, …) still work
as **hidden back-compat aliases** during the transition and are dropped from
@ -27,9 +27,7 @@ as **hidden back-compat aliases** during the transition and are dropped from
```bash
hive-forge pr close 42 # close a PR (kind-validated)
hive-forge pr reopen 42 # reopen a closed PR (kind-validated)
hive-forge issue close 42 # close an issue (kind-validated)
hive-forge issue reopen 42 # reopen a closed issue (kind-validated)
hive-forge pr status --pr 42 # PR health (mergeable / CI / reviews)
hive-forge issue create --title "..." --body "..."
# --- flat aliases below remain valid (hidden) ---

View file

@ -925,7 +925,7 @@ frosted-mauve bar slides up from the bottom of the viewport
- `■ ST0P` — running agents only
- `▶ ST4RT` — stopped agents only
- `↻ R3BU1LD` — always available
- `DESTR0Y` / `PURG3`always available
- `DESTR0Y` / `PURG3`sub-agents only (disabled if the root/bootstrap container selected)
- `⇡ M0V3 → ROOT` — promote selected agents to top-level
(parent = null); disabled when all selected are already at root.
Backend `topology::set_parent` refuses moves it can't satisfy

View file

@ -314,18 +314,17 @@ export function renderApprovals() {
for (const a of pending) {
const isApply = a.kind === 'apply_commit';
const isInit = a.kind === 'init_config';
const isMergePr = a.kind === 'merge_config_pr';
const li = el('li', { class: 'approval-card' });
// ── identity header ──────────────────────────────────────────
const head = el('div', { class: 'approval-head' },
el('span', { class: 'glyph' }, isApply ? '→' : isMergePr ? '⇒' : '⊕'),
el('span', { class: 'glyph' }, isApply ? '→' : '⊕'),
el('span', { class: 'id' }, '#' + a.id),
el('span', { class: 'agent' }, a.agent),
el('span', { class: 'kind' + ((isApply || isMergePr) ? '' : ' kind-spawn') },
isApply ? 'apply' : isMergePr ? 'merge-pr' : isInit ? 'init' : 'spawn'),
el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
isApply ? 'apply' : isInit ? 'init' : 'spawn'),
);
if ((isApply || isMergePr) && a.sha_short) head.append(el('code', {}, a.sha_short));
if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short));
// When the approval was requested — relative time, right-aligned.
// Goes amber once it's been pending an hour so a stale request is
// obvious at a glance (see docs/web-ui.md::Approval card).
@ -358,19 +357,6 @@ export function renderApprovals() {
}, '↳ commit on forge ↗'));
}
body.append(drill);
} else if (isMergePr) {
// PR-based config deploy: link to the reviewed PR on the forge
// (mirrors the apply_commit "commit on forge" link). The config
// diff side-panel is apply_commit-only for now.
const drill = el('div', { class: 'drill-ins' });
if (forgeBase && a.pr_number != null) {
drill.append(el('a', {
class: 'panel-trigger', target: '_blank', rel: 'noopener',
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
title: 'review this config PR on the hive forge',
}, '↳ review PR on forge ↗'));
}
body.append(drill);
} else {
body.append(el('span', { class: 'meta' },
isInit
@ -417,7 +403,7 @@ function renderApprovalHistory(root, history) {
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
);
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
row.append(

View file

@ -249,8 +249,8 @@ window.marked = marked;
// ─── per-agent context menu ──────────────────────────────────────────
// Three-dot (⋮) button on each agent card for quick single-agent
// lifecycle actions without needing to select first. State-aware:
// restart/stop only shown when running, start only shown when
// stopped; rebuild + destroy/purge always shown.
// restart/stop only shown when running, start only shown when stopped,
// destroy/purge hidden for the manager.
// The button is CSS-invisible until the row is hovered (or menu is
// open) so it doesn't clutter quiet rows.
@ -383,18 +383,20 @@ window.marked = marked;
`view ${c.name} journal logs`),
);
dropdown.append(
menuSep(),
menuItem('DESTR0Y', {
action: '/api/destroy/',
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
}),
menuItem('PURG3', {
action: '/api/destroy/',
body: { purge: 'on' },
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
}),
);
{
dropdown.append(
menuSep(),
menuItem('DESTR0Y', {
action: '/api/destroy/',
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
}),
menuItem('PURG3', {
action: '/api/destroy/',
body: { purge: 'on' },
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
}),
);
}
if (c.deployed_sha && forgeBase) {
const li = el('li', { role: 'presentation' });

View file

@ -382,7 +382,8 @@ async fn format_notification(
};
let is_pr = matches!(notif_type, "Pull Request" | "Pull");
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr);
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.
@ -395,6 +396,7 @@ async fn format_notification(
num,
repo,
meta_suffix,
reason,
subject,
is_pr,
};
@ -409,7 +411,7 @@ async fn format_notification(
)
.await
} else {
Some(format_state_change_notification(notif, &meta, own_login))
format_state_change_notification(notif, &meta, own_login)
}
}
@ -421,15 +423,19 @@ struct NotifMeta<'a> {
num: String,
repo: String,
meta_suffix: String,
/// Forgejo `reason` value (e.g. "mention", "assigned", "subscribed").
/// Appended to every wrapper as the `reason:` line in the meta
/// suffix (see `docs/forge.md::Meta suffix`).
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: ...`)
/// suffix appended to every wrapper. Shape + presence rules live in
/// `docs/forge.md::Meta suffix`.
fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool) -> String {
/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...` and
/// `\nreason: ...`) suffix appended to every wrapper. Shape +
/// presence rules live in `docs/forge.md::Meta suffix`.
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())
@ -453,10 +459,21 @@ fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool) -> String
} else {
None
};
// Always include reason so multiple notifications for the same
// event (each with a different Forgejo reason) stay
// distinguishable.
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
}
@ -557,7 +574,7 @@ fn format_state_change_notification(
notif: &serde_json::Value,
meta: &NotifMeta<'_>,
own_login: &str,
) -> String {
) -> 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`:
@ -566,9 +583,14 @@ fn format_state_change_notification(
// - Forgejo API type is "Pull" / "Issue", never "Pull Request".
let notif_state = notif["subject"]["state"].as_str().unwrap_or("");
// "New" = the subject is open (or state is absent). Used below for
// the review-request override.
// Self-notification filter: drop new items we authored ourselves
// (`reason == "author"` + open state). State transitions on our
// own PRs (merge / close) come from someone else, so those stay.
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,
@ -577,6 +599,7 @@ fn format_state_change_notification(
num,
repo,
meta_suffix,
reason: _,
subject,
is_pr,
} = meta;
@ -635,7 +658,7 @@ fn format_state_change_notification(
let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}");
out.push_str(meta_suffix);
out
Some(out)
}
/// Decide whether a state-change notification represents the subject's

View file

@ -131,15 +131,6 @@ pub struct Coordinator {
/// agent is in this set — the inbound fence. Cleared when the agent
/// reports `GracefulStopComplete` or the container is stopped.
graceful_stop_pending: Mutex<HashSet<String>>,
/// Logical agent names that were running at the last broad-scope
/// `hivectl stop`. A subsequent broad-scope `hivectl start` restores
/// only this set (intersected with the requested scope) rather than
/// every configured container, so agents the operator intentionally
/// left stopped stay stopped. `None` when no broad stop has happened
/// since the last start (or since daemon boot) — start then falls
/// back to "start all". In-daemon memory only (hive-c0re survives
/// `hivectl stop`); host-reboot persistence is a separate follow-up.
last_stopped_running: Mutex<Option<Vec<String>>>,
/// 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
@ -389,20 +380,6 @@ pub struct ApprovalResolved<'a> {
pub description: Option<String>,
}
/// Field-named payload for [`Coordinator::emit_approval_added`].
/// Mirrors the `ApprovalAdded` dashboard-event fields. `agent`
/// borrows from the caller; `approval_kind` is a compile-time
/// constant. `pr_number` is set for `merge_config_pr` only.
pub struct ApprovalAdded<'a> {
pub id: i64,
pub agent: &'a str,
pub approval_kind: &'static str,
pub sha_short: Option<String>,
pub diff: Option<String>,
pub description: Option<String>,
pub pr_number: Option<u64>,
}
/// Field-named payload for [`Coordinator::emit_question_added`].
/// Mirrors the `QuestionAdded` dashboard-event fields; all references
/// share the caller's lifetime.
@ -480,7 +457,6 @@ impl Coordinator {
recent_transient: Mutex::new(HashMap::new()),
recent_crashes: Mutex::new(HashMap::new()),
graceful_stop_pending: Mutex::new(HashSet::new()),
last_stopped_running: Mutex::new(None),
dashboard_events,
event_seq: AtomicU64::new(0),
meta_updates_active: AtomicU64::new(0),
@ -754,16 +730,15 @@ impl Coordinator {
/// 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, ev: ApprovalAdded<'_>) {
let ApprovalAdded {
id,
agent,
approval_kind,
sha_short,
diff,
description,
pr_number,
} = ev;
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,
@ -772,7 +747,6 @@ impl Coordinator {
sha_short,
diff,
description,
pr_number,
});
}
@ -1183,20 +1157,6 @@ impl Coordinator {
self.graceful_stop_pending.lock().unwrap().remove(name);
}
/// Record the set of agents that were running at a broad-scope
/// `hivectl stop`, so the next broad-scope `start` restores exactly
/// this set. See the `last_stopped_running` field doc.
pub fn set_last_stopped_running(&self, agents: Vec<String>) {
*self.last_stopped_running.lock().unwrap() = Some(agents);
}
/// Take (and clear) the recorded broad-stop running set, if any. A
/// broad-scope `start` uses this to restore only the previously
/// running agents; `None` means "no record — start all".
pub fn take_last_stopped_running(&self) -> Option<Vec<String>> {
self.last_stopped_running.lock().unwrap().take()
}
/// 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.

View file

@ -9,7 +9,7 @@ use std::time::Duration;
use crate::container_view::claude_has_session;
use crate::coordinator::{Coordinator, TransientKind};
use crate::lifecycle::{self, AGENT_PREFIX};
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
const POLL_INTERVAL: Duration = Duration::from_secs(10);
@ -37,11 +37,15 @@ pub fn spawn(coord: Arc<Coordinator>) {
continue;
};
let logical = logical.to_owned();
sub_agents.push(logical.clone());
if logical != MANAGER_NAME {
sub_agents.push(logical.clone());
}
if lifecycle::is_running(&logical).await {
current_running.insert(logical.clone());
}
if claude_has_session(&Coordinator::agent_claude_dir(&logical)) {
if logical != MANAGER_NAME
&& claude_has_session(&Coordinator::agent_claude_dir(&logical))
{
current_logged_in.insert(logical.clone());
}
}

View file

@ -979,8 +979,9 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
out
}
/// State-dir names that don't appear in the live container list. Each
/// one surfaces in the dashboard as a row with R3V1V3 + PURG3 actions.
/// State-dir names that don't appear in the live container list (and
/// aren't the manager). Each one surfaces in the dashboard as a row
/// with R3V1V3 + PURG3 actions.
fn build_tombstone_views(
coord: &Coordinator,
containers: &[ContainerView],
@ -994,7 +995,7 @@ fn build_tombstone_views(
.collect();
Coordinator::kept_state_names()
.into_iter()
.filter(|name| !live.contains(name.as_str()))
.filter(|name| name != MANAGER_NAME && !live.contains(name.as_str()))
.map(|name| {
let root = Coordinator::agent_state_root(&name);
let state_bytes = dir_size_bytes(&root);
@ -1662,15 +1663,7 @@ async fn post_request_spawn(
// refetch. Spawn approvals carry no diff/sha.
state
.coord
.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent: &name,
approval_kind: "spawn",
sha_short: None,
diff: None,
description: None,
pr_number: None,
});
.emit_approval_added(id, &name, "spawn", None, None, None);
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),

View file

@ -76,13 +76,6 @@ pub enum DashboardEvent {
sha_short: Option<String>,
diff: Option<String>,
description: Option<String>,
/// Forge PR number, for `merge_config_pr` approvals only — lets
/// the live `applyApprovalAdded` path build the "review PR on
/// forge" link without waiting for a cold `/api/state` refresh
/// (mirrors `ApprovalView::pr_number`). `None` for every other
/// kind.
#[serde(skip_serializing_if = "Option::is_none")]
pr_number: Option<u64>,
},
/// A pending approval transitioned to a terminal state
/// (approved / denied / failed). Clients move the row out of the
@ -353,7 +346,6 @@ mod tests {
sha_short: None,
diff: None,
description: None,
pr_number: None,
},
DashboardEvent::ApprovalResolved {
seq: 1,

View file

@ -101,32 +101,10 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
// graceful-stop queue, to re-expand it).
let agents = scoped_agents(scope).await?;
let infra = scoped_infra(scope);
// On a broad stop, remember which agents were actually
// running so a later broad `start` restores only those
// (not every configured container). A targeted `--agent`
// stop must not redefine the restore set.
if is_broad_scope(scope) {
let mut running = Vec::new();
for a in &agents {
if lifecycle::is_running(a).await {
running.push(a.clone());
}
}
coord.set_last_stopped_running(running);
}
handle_stop(&coord, &agents, &infra, *graceful).await?
}
HostRequest::Start { scope } => {
let mut agents = scoped_agents(scope).await?;
// A broad start restores only the set recorded at the
// last broad stop, if any. No record (cold "bring the
// hive up", or a daemon restart since the stop) → start
// all. Targeted `--agent` start is never filtered.
if is_broad_scope(scope)
&& let Some(prev) = coord.take_last_stopped_running()
{
agents.retain(|a| prev.contains(a));
}
let agents = scoped_agents(scope).await?;
let infra = scoped_infra(scope);
handle_start(&agents, &infra).await?
}
@ -350,19 +328,10 @@ async fn handle_start(agents: &[String], infra: &[InfraContainer]) -> Result<Hos
/// container (from `lifecycle::list`) when `agents` is set or the scope is
/// "everything", plus any explicit `agent_names`. Returns de-duplicated
/// logical names with the `h-` container prefix stripped.
/// A scope that targets *every* agent rather than an explicit
/// `--agent <name>` list: either the `agents` flag or a bare
/// "everything" scope. Broad scopes are the ones whose stop/start pair
/// drives the previously-running restore set (see `handle` Stop/Start
/// arms); a targeted `--agent` stop/start must not redefine it.
fn is_broad_scope(scope: &LifecycleScope) -> bool {
scope.agents || scope.is_everything()
}
async fn scoped_agents(scope: &LifecycleScope) -> Result<Vec<String>> {
use std::collections::BTreeSet;
let mut set: BTreeSet<String> = BTreeSet::new();
if is_broad_scope(scope) {
if scope.agents || scope.is_everything() {
for c in lifecycle::list().await? {
let logical = c
.strip_prefix(lifecycle::AGENT_PREFIX)

View file

@ -1438,15 +1438,14 @@ fn handle_request_update_meta_inputs(
}
};
tracing::info!(%id, %label, "update_meta_inputs approval queued");
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
coord.emit_approval_added(
id,
agent: requester,
approval_kind: "update_meta_inputs",
sha_short: None,
diff: None,
description: description.map(str::to_owned),
pr_number: None,
});
requester,
"update_meta_inputs",
None,
None,
description.map(str::to_owned),
);
AgentResponse::Ok
}
@ -1543,15 +1542,14 @@ fn handle_request_schedule_prompt(
interval = ?payload.interval_seconds,
"schedule_prompt approval queued"
);
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
coord.emit_approval_added(
id,
agent: requester,
approval_kind: "schedule_prompt",
sha_short: None,
diff: None,
description: payload.description.clone(),
pr_number: None,
});
requester,
"schedule_prompt",
None,
None,
payload.description.clone(),
);
AgentResponse::Ok
}
@ -1804,15 +1802,7 @@ pub(crate) fn submit_init_config(
)
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
tracing::info!(%id, %name, "init_config approval queued");
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent: name,
approval_kind: "init_config",
sha_short: None,
diff: None,
description,
pr_number: None,
});
coord.emit_approval_added(id, name, "init_config", None, None, description);
Ok(id)
}
@ -1938,15 +1928,14 @@ pub(crate) async fn submit_apply_commit(
// get a fully-formed row without a snapshot refetch. `sha_short`
// is reused from the dedup gate above.
let diff = crate::dashboard::approval_diff(agent, id).await;
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
coord.emit_approval_added(
id,
agent,
approval_kind: "apply_commit",
sha_short: Some(sha_short),
diff: Some(diff),
description: description.map(str::to_owned),
pr_number: None,
});
"apply_commit",
Some(sha_short),
Some(diff),
description.map(str::to_owned),
);
Ok((id, sha))
}

View file

@ -315,9 +315,8 @@ pub fn apply_add_child(
}
/// Reconcile `topology.json` against the current agent set. Adds an
/// entry (default: parent = null — a new agent with no declared parent
/// is its own root) for any agent missing from the file; removes
/// entries for agents no longer
/// entry (default: parent = manager, manager itself = root) for any
/// agent missing from the file; removes entries for agents no longer
/// present. Existing entries are preserved as-is — operator/manager
/// choices stick across regenerations. Returns true when the file
/// changed and should be re-committed by the caller.

View file

@ -55,7 +55,7 @@ enum Verb {
/// Dump title + body + all comments for an issue or PR.
#[command(hide = true)]
View(verbs::view::Args),
/// Issue-scoped commands: `issue <show|create|edit|view|comment|comments|close|reopen|labels|assign|timeline> …`.
/// Issue-scoped commands: `issue <show|create|edit|view|comment|comments|close|labels|assign|timeline> …`.
Issue(verbs::issue_cmd::Args),
/// Create an issue. Prints the issue URL on success.
#[command(hide = true)]
@ -63,7 +63,7 @@ enum Verb {
/// Edit an issue's title, body, state, or milestone.
#[command(hide = true)]
IssueEdit(verbs::issue_edit::Args),
/// PR-scoped commands: `pr <show|status|create|merge|reviews|commits|diff|view|comment|comments|close|reopen|labels|assign|timeline> …`.
/// PR-scoped commands: `pr <show|status|create|merge|reviews|commits|diff|view|comment|comments|close|labels|assign|timeline> …`.
Pr(verbs::pr_cmd::Args),
/// List a PR's commits as JSON (sha, message, author date, author).
/// Survives rebase-rewritten shas — message + author date let a

View file

@ -1,7 +1,7 @@
//! `issue <verb>` — issue-scoped sub-commands. Wraps the per-verb modules
//! under an `issue` parent so `hive-forge issue close 42`, `issue create …`,
//! etc. read as kind-namespaced commands. The generic verbs that also work on
//! PRs (view/comment/comments/close/reopen/labels/assign/timeline) kind-check the
//! PRs (view/comment/comments/close/labels/assign/timeline) kind-check the
//! number is an issue first (`assert_kind`); the issue-only verbs are
//! kind-correct by construction. The flat `issue-*` + bare generic verbs stay
//! as hidden back-compat aliases (see `main.rs`).
@ -34,8 +34,6 @@ enum Cmd {
Comments(verbs::comments::Args),
/// Close the issue.
Close(verbs::close::Args),
/// Reopen a closed issue.
Reopen(verbs::reopen::Args),
/// List / add / remove labels.
Labels(verbs::labels::Args),
/// Assign or unassign a user.
@ -67,10 +65,6 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
assert_kind(client, a.number, Kind::Issue)?;
verbs::close::run(client, a)
}
Cmd::Reopen(a) => {
assert_kind(client, a.number, Kind::Issue)?;
verbs::reopen::run(client, a)
}
Cmd::Labels(a) => {
assert_kind(client, a.number, Kind::Issue)?;
verbs::labels::run(client, a)

View file

@ -32,7 +32,6 @@ pub mod pr_create;
pub mod pr_merge;
pub mod pr_reviews;
pub mod pr_status;
pub mod reopen;
pub mod repo_add_collaborator;
pub mod repo_create;
pub mod repo_labels;

View file

@ -1,7 +1,7 @@
//! `pr <verb>` — PR-scoped sub-commands. Wraps the per-verb modules under a
//! `pr` parent so `hive-forge pr close 42`, `pr status --pr 42`, etc. read as
//! kind-namespaced commands. The generic verbs that also work on issues
//! (view/comment/comments/close/reopen/labels/assign/timeline) kind-check the number
//! (view/comment/comments/close/labels/assign/timeline) kind-check the number
//! is a PR first (`assert_kind`); the PR-only verbs hit `/pulls/…` and are
//! kind-correct by construction. The flat `pr-*` + bare generic verbs stay as
//! hidden back-compat aliases (see `main.rs`).
@ -42,8 +42,6 @@ enum Cmd {
Comments(verbs::comments::Args),
/// Close the PR.
Close(verbs::close::Args),
/// Reopen a closed PR.
Reopen(verbs::reopen::Args),
/// List / add / remove labels.
Labels(verbs::labels::Args),
/// Assign or unassign a user.
@ -79,10 +77,6 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
assert_kind(client, a.number, Kind::Pr)?;
verbs::close::run(client, a)
}
Cmd::Reopen(a) => {
assert_kind(client, a.number, Kind::Pr)?;
verbs::reopen::run(client, a)
}
Cmd::Labels(a) => {
assert_kind(client, a.number, Kind::Pr)?;
verbs::labels::run(client, a)

View file

@ -1,35 +0,0 @@
//! `reopen <number>` — reopen a closed issue or PR.
//!
//! Mirror of `close`: sends a PATCH setting the issue/PR `state` back to
//! `open`. PRs share the issue number space, so the same `/issues/<n>`
//! endpoint reopens either.
use anyhow::Result;
use clap::Args as ClapArgs;
use serde_json::json;
use crate::client::Client;
use crate::verbs::print_json;
#[derive(ClapArgs)]
pub struct Args {
/// Issue or PR number.
pub(crate) number: u64,
}
/// # Errors
///
/// Returns an error when the `PATCH /repos/{repo}/issues/{number}` request
/// fails (network / non-success status from `patch_json`) or when emitting
/// the JSON summary via `print_json` fails.
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let resp = client.patch_json(
&format!("/repos/{repo}/issues/{}", args.number),
&json!({ "state": "open" }),
)?;
print_json(&json!({
"number": resp.get("number"),
"state": resp.get("state"),
}))
}

View file

@ -174,71 +174,6 @@ in
visible = false;
};
options.hyperhive.otel = {
enable = lib.mkEnableOption ''
exporting this agent's Claude Code stats (token usage, cost, tool
calls) to an OTLP endpoint via Claude Code's built-in OpenTelemetry.
Each agent's harness exports its own stats directly to the collector,
so it keeps working even when hive-c0re is down. Meant to be enabled
hive-wide (one switch for every agent) - there is no per-agent
opt-in flag beyond this option
'';
endpoint = lib.mkOption {
type = lib.types.str;
default = "";
example = "https://collector.example.com/otel";
description = ''
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`.
Required when `enable` is true.
'';
};
protocol = lib.mkOption {
type = lib.types.enum [
"http/protobuf"
"http/json"
"grpc"
];
default = "http/protobuf";
description = ''
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`.
'';
};
headersCredential = lib.mkOption {
# `str`, not `path`: a `path`-typed *relative* literal (e.g.
# `./otel-headers`) is hash-copied into the world-readable nix store
# at eval time, which would defeat the whole point of this option.
# Keep it a string and require an absolute runtime path so the secret
# is only ever read from disk by systemd at start, never nix-stored.
type = lib.types.nullOr lib.types.str;
default = null;
example = "/run/secrets/otel-headers";
description = ''
Absolute path to an operator-provided secret file whose contents
become `OTEL_EXPORTER_OTLP_HEADERS` (e.g.
`Authorization=Bearer <token>`). Loaded via systemd
`LoadCredential` into the unit-private credential store at
runtime, so the token is never copied into the nix store or
exposed in the process argv. Must be an absolute path (systemd
`LoadCredential` requires one). Leave null if the endpoint needs
no auth header.
'';
};
extraResourceAttributes = lib.mkOption {
type = lib.types.str;
default = "";
example = "deployment.environment=prod";
description = ''
Extra comma-separated entries appended to
`OTEL_RESOURCE_ATTRIBUTES` after the built-in
`service.name` / `agent` / `hive` / `swarm` labels.
'';
};
};
options.hyperhive.allowedRecipients = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
@ -787,11 +722,6 @@ in
'';
assertions = [
# OTEL export needs an endpoint to point at.
{
assertion = !config.hyperhive.otel.enable || config.hyperhive.otel.endpoint != "";
message = "hyperhive.otel.enable is true but hyperhive.otel.endpoint is empty.";
}
# Guard the inputs-routed-as-output pattern: the agent flake.nix is
# expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`.
# If `self` leaks into flakeInputs the agent gets a spurious attrset
@ -1710,37 +1640,6 @@ in
systemd.services.hive-ag3nt =
let
binary = "hive";
otel = config.hyperhive.otel;
# Claude Code's native OpenTelemetry is env-driven; the harness
# spawns `claude` as a child which inherits this unit's env, so
# setting these here is all it takes to export per-agent stats.
otelEnv = lib.optionalAttrs otel.enable {
CLAUDE_CODE_ENABLE_TELEMETRY = "1";
OTEL_METRICS_EXPORTER = "otlp";
OTEL_LOGS_EXPORTER = "otlp";
# Route traces to OTLP too so any spans Claude Code emits land
# at the configured collector rather than a default exporter.
OTEL_TRACES_EXPORTER = "otlp";
OTEL_EXPORTER_OTLP_PROTOCOL = otel.protocol;
OTEL_EXPORTER_OTLP_ENDPOINT = otel.endpoint;
};
# When OTEL is on, wrap the harness launch so (1) the bearer-token
# header is read from the systemd credential at start (never in the
# nix store or argv) and (2) the resource attributes are assembled
# from this agent's name (known at build time) plus the hive/swarm
# names (inherited HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env,
# the same vars `identity::hive_name`/`swarm_name` read at runtime).
otelExecStart = pkgs.writeShellScript "hive-serve-otel" ''
set -eu
if [ -n "''${CREDENTIALS_DIRECTORY:-}" ] && [ -r "$CREDENTIALS_DIRECTORY/otel-headers" ]; then
OTEL_EXPORTER_OTLP_HEADERS="$(cat "$CREDENTIALS_DIRECTORY/otel-headers")"
export OTEL_EXPORTER_OTLP_HEADERS
fi
export OTEL_RESOURCE_ATTRIBUTES="service.name=hyperhive-agent,agent=${userName},hive=''${HYPERHIVE_HIVE_NAME:-unknown},swarm=''${HYPERHIVE_SWARM_NAME:-unknown}${
lib.optionalString (otel.extraResourceAttributes != "") ",${otel.extraResourceAttributes}"
}"
exec ${pkgs.hyperhive}/bin/${binary} serve
'';
in
{
description = "${binary} harness";
@ -1763,10 +1662,9 @@ in
# `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle
# bind-mounts and gateway upstream config stay in sync.
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
}
// otelEnv;
};
serviceConfig = {
ExecStart = if otel.enable then "${otelExecStart}" else "${pkgs.hyperhive}/bin/${binary} serve";
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
Restart = "on-failure";
RestartSec = 2;
# Per-service runtime dir owned by `User=` below; the harness
@ -1776,9 +1674,6 @@ in
RuntimeDirectory = "hive-config";
User = userName;
Group = userName;
}
// lib.optionalAttrs (otel.enable && otel.headersCredential != null) {
LoadCredential = [ "otel-headers:${otel.headersCredential}" ];
};
};