diff --git a/docs/forge.md b/docs/forge.md index a2dfd793..527ca5ac 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -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. Two filter rules drop them silently -(mark-read without delivery): +loop claude on its own writes. The comment/review case is dropped +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: ...\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\nassignee: ...\nreason: subscribed` | -| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...\nreason: subscribed` | -| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...\nreason: subscribed` | +| 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\nassignee: ...` | +| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...` | +| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` | Review labels come from the Forgejo `state` field: `APPROVED` → `approved`, `REQUEST_CHANGES` → `changes requested`, `COMMENT` → @@ -222,14 +222,6 @@ Every wrapper ends with one or more of: the line shape is stable. - `reviewer: ` — PR notifications only, present only when `requested_reviewers` is non-empty. -- `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 diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 4240360d..2e4fdd53 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -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/labels/assign/timeline as applicable). +comments/close/reopen/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,7 +27,9 @@ 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) --- diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 3d34ba73..78fb338d 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -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` — sub-agents only (disabled if the root/bootstrap container selected) + - `DESTR0Y` / `PURG3` — always available - `⇡ 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 diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index 967f89c4..6c0965a9 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -314,17 +314,18 @@ 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 ? '→' : '⊕'), + el('span', { class: 'glyph' }, isApply ? '→' : isMergePr ? '⇒' : '⊕'), el('span', { class: 'id' }, '#' + a.id), el('span', { class: 'agent' }, a.agent), - el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') }, - isApply ? 'apply' : isInit ? 'init' : 'spawn'), + el('span', { class: 'kind' + ((isApply || isMergePr) ? '' : ' kind-spawn') }, + isApply ? 'apply' : isMergePr ? 'merge-pr' : isInit ? 'init' : 'spawn'), ); - if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short)); + if ((isApply || isMergePr) && 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). @@ -357,6 +358,19 @@ 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 @@ -403,7 +417,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 === 'init_config' ? 'init' : 'spawn'), ' ', + el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ', ); if (a.sha_short) row.append(el('code', {}, a.sha_short), ' '); row.append( diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index bfa66b06..8b9f4c5a 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -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, - // destroy/purge hidden for the manager. + // restart/stop only shown when running, start only shown when + // stopped; rebuild + destroy/purge always shown. // The button is CSS-invisible until the row is hovered (or menu is // open) so it doesn't clutter quiet rows. @@ -383,20 +383,18 @@ 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' }); diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index b56b4c7d..4a8c5b26 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -382,8 +382,7 @@ async fn format_notification( }; 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); + let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr); // Determine whether this notification was triggered by a comment/review or // by creation/state-change of the subject itself. @@ -396,7 +395,6 @@ async fn format_notification( num, repo, meta_suffix, - reason, subject, is_pr, }; @@ -411,7 +409,7 @@ async fn format_notification( ) .await } else { - format_state_change_notification(notif, &meta, own_login) + Some(format_state_change_notification(notif, &meta, own_login)) } } @@ -423,19 +421,15 @@ 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, is_pr: bool, } -/// 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 { +/// 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 { let assignees: Vec<&str> = subject .and_then(|s| s["assignees"].as_array()) .map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect()) @@ -459,21 +453,10 @@ fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: & } 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 } @@ -574,7 +557,7 @@ 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`: @@ -583,14 +566,9 @@ fn format_state_change_notification( // - Forgejo API type is "Pull" / "Issue", never "Pull Request". let notif_state = notif["subject"]["state"].as_str().unwrap_or(""); - // 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. + // "New" = the subject is open (or state is absent). Used below for + // the review-request override. 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, @@ -599,7 +577,6 @@ fn format_state_change_notification( num, repo, meta_suffix, - reason: _, subject, is_pr, } = meta; @@ -658,7 +635,7 @@ fn format_state_change_notification( let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}"); out.push_str(meta_suffix); - Some(out) + out } /// Decide whether a state-change notification represents the subject's diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 383e2a23..a4591049 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -131,6 +131,15 @@ 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>, + /// 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>>, /// 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 @@ -380,6 +389,20 @@ pub struct ApprovalResolved<'a> { pub description: Option, } +/// 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, + pub diff: Option, + pub description: Option, + pub pr_number: Option, +} + /// Field-named payload for [`Coordinator::emit_question_added`]. /// Mirrors the `QuestionAdded` dashboard-event fields; all references /// share the caller's lifetime. @@ -457,6 +480,7 @@ 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), @@ -730,15 +754,16 @@ 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, - id: i64, - agent: &str, - approval_kind: &'static str, - sha_short: Option, - diff: Option, - description: Option, - ) { + pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) { + let ApprovalAdded { + id, + agent, + approval_kind, + sha_short, + diff, + description, + pr_number, + } = ev; self.emit_dashboard_event(DashboardEvent::ApprovalAdded { seq: self.next_seq(), id, @@ -747,6 +772,7 @@ impl Coordinator { sha_short, diff, description, + pr_number, }); } @@ -1157,6 +1183,20 @@ 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) { + *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> { + 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. diff --git a/hive-c0re/src/crash_watch.rs b/hive-c0re/src/crash_watch.rs index c7cca394..e5ebacdf 100644 --- a/hive-c0re/src/crash_watch.rs +++ b/hive-c0re/src/crash_watch.rs @@ -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, MANAGER_NAME}; +use crate::lifecycle::{self, AGENT_PREFIX}; const POLL_INTERVAL: Duration = Duration::from_secs(10); @@ -37,15 +37,11 @@ pub fn spawn(coord: Arc) { continue; }; let logical = logical.to_owned(); - if logical != MANAGER_NAME { - sub_agents.push(logical.clone()); - } + 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)) - { + if claude_has_session(&Coordinator::agent_claude_dir(&logical)) { current_logged_in.insert(logical.clone()); } } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index a321b31e..5b4c6e90 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -979,9 +979,8 @@ async fn build_approval_views(approvals: Vec) -> Vec { out } -/// 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. +/// 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. fn build_tombstone_views( coord: &Coordinator, containers: &[ContainerView], @@ -995,7 +994,7 @@ fn build_tombstone_views( .collect(); Coordinator::kept_state_names() .into_iter() - .filter(|name| name != MANAGER_NAME && !live.contains(name.as_str())) + .filter(|name| !live.contains(name.as_str())) .map(|name| { let root = Coordinator::agent_state_root(&name); let state_bytes = dir_size_bytes(&root); @@ -1663,7 +1662,15 @@ async fn post_request_spawn( // refetch. Spawn approvals carry no diff/sha. state .coord - .emit_approval_added(id, &name, "spawn", None, None, None); + .emit_approval_added(crate::coordinator::ApprovalAdded { + id, + agent: &name, + approval_kind: "spawn", + sha_short: None, + diff: None, + description: None, + pr_number: None, + }); (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")), diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 7cf02437..13307bcb 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -76,6 +76,13 @@ pub enum DashboardEvent { sha_short: Option, diff: Option, description: Option, + /// 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, }, /// A pending approval transitioned to a terminal state /// (approved / denied / failed). Clients move the row out of the @@ -346,6 +353,7 @@ mod tests { sha_short: None, diff: None, description: None, + pr_number: None, }, DashboardEvent::ApprovalResolved { seq: 1, diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 2248cb90..b774253e 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -101,10 +101,32 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> 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 agents = scoped_agents(scope).await?; + 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 infra = scoped_infra(scope); handle_start(&agents, &infra).await? } @@ -328,10 +350,19 @@ async fn handle_start(agents: &[String], infra: &[InfraContainer]) -> Result` 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> { use std::collections::BTreeSet; let mut set: BTreeSet = BTreeSet::new(); - if scope.agents || scope.is_everything() { + if is_broad_scope(scope) { for c in lifecycle::list().await? { let logical = c .strip_prefix(lifecycle::AGENT_PREFIX) diff --git a/hive-c0re/src/socket_server.rs b/hive-c0re/src/socket_server.rs index d7722f4e..f26abe62 100644 --- a/hive-c0re/src/socket_server.rs +++ b/hive-c0re/src/socket_server.rs @@ -1438,14 +1438,15 @@ fn handle_request_update_meta_inputs( } }; tracing::info!(%id, %label, "update_meta_inputs approval queued"); - coord.emit_approval_added( + coord.emit_approval_added(crate::coordinator::ApprovalAdded { id, - requester, - "update_meta_inputs", - None, - None, - description.map(str::to_owned), - ); + agent: requester, + approval_kind: "update_meta_inputs", + sha_short: None, + diff: None, + description: description.map(str::to_owned), + pr_number: None, + }); AgentResponse::Ok } @@ -1542,14 +1543,15 @@ fn handle_request_schedule_prompt( interval = ?payload.interval_seconds, "schedule_prompt approval queued" ); - coord.emit_approval_added( + coord.emit_approval_added(crate::coordinator::ApprovalAdded { id, - requester, - "schedule_prompt", - None, - None, - payload.description.clone(), - ); + agent: requester, + approval_kind: "schedule_prompt", + sha_short: None, + diff: None, + description: payload.description.clone(), + pr_number: None, + }); AgentResponse::Ok } @@ -1802,7 +1804,15 @@ 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(id, name, "init_config", None, None, description); + coord.emit_approval_added(crate::coordinator::ApprovalAdded { + id, + agent: name, + approval_kind: "init_config", + sha_short: None, + diff: None, + description, + pr_number: None, + }); Ok(id) } @@ -1928,14 +1938,15 @@ 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( + coord.emit_approval_added(crate::coordinator::ApprovalAdded { id, agent, - "apply_commit", - Some(sha_short), - Some(diff), - description.map(str::to_owned), - ); + approval_kind: "apply_commit", + sha_short: Some(sha_short), + diff: Some(diff), + description: description.map(str::to_owned), + pr_number: None, + }); Ok((id, sha)) } diff --git a/hive-c0re/src/topology.rs b/hive-c0re/src/topology.rs index ce2f879d..19c6c278 100644 --- a/hive-c0re/src/topology.rs +++ b/hive-c0re/src/topology.rs @@ -315,8 +315,9 @@ pub fn apply_add_child( } /// Reconcile `topology.json` against the current agent set. Adds an -/// entry (default: parent = manager, manager itself = root) for any -/// agent missing from the file; removes entries for agents no longer +/// 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 /// 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. diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 6735f718..a733be23 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -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 …`. + /// Issue-scoped commands: `issue …`. 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 …`. + /// PR-scoped commands: `pr …`. 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 diff --git a/hive-forge/src/verbs/issue_cmd.rs b/hive-forge/src/verbs/issue_cmd.rs index 993cbaeb..886f0611 100644 --- a/hive-forge/src/verbs/issue_cmd.rs +++ b/hive-forge/src/verbs/issue_cmd.rs @@ -1,7 +1,7 @@ //! `issue ` — 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/labels/assign/timeline) kind-check the +//! PRs (view/comment/comments/close/reopen/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,6 +34,8 @@ 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. @@ -65,6 +67,10 @@ 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) diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index 0e2bc60c..60b4bb99 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -32,6 +32,7 @@ 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; diff --git a/hive-forge/src/verbs/pr_cmd.rs b/hive-forge/src/verbs/pr_cmd.rs index ea380166..533100f3 100644 --- a/hive-forge/src/verbs/pr_cmd.rs +++ b/hive-forge/src/verbs/pr_cmd.rs @@ -1,7 +1,7 @@ //! `pr ` — 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/labels/assign/timeline) kind-check the number +//! (view/comment/comments/close/reopen/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,6 +42,8 @@ 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. @@ -77,6 +79,10 @@ 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) diff --git a/hive-forge/src/verbs/reopen.rs b/hive-forge/src/verbs/reopen.rs new file mode 100644 index 00000000..68208375 --- /dev/null +++ b/hive-forge/src/verbs/reopen.rs @@ -0,0 +1,35 @@ +//! `reopen ` — 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/` +//! 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"), + })) +} diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index ecb00ca8..dae0e750 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -174,6 +174,71 @@ 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 `). 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 = [ ]; @@ -722,6 +787,11 @@ 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 @@ -1640,6 +1710,37 @@ 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"; @@ -1662,9 +1763,10 @@ 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 = "${pkgs.hyperhive}/bin/${binary} serve"; + ExecStart = if otel.enable then "${otelExecStart}" else "${pkgs.hyperhive}/bin/${binary} serve"; Restart = "on-failure"; RestartSec = 2; # Per-service runtime dir owned by `User=` below; the harness @@ -1674,6 +1776,9 @@ in RuntimeDirectory = "hive-config"; User = userName; Group = userName; + } + // lib.optionalAttrs (otel.enable && otel.headersCredential != null) { + LoadCredential = [ "otel-headers:${otel.headersCredential}" ]; }; };