Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12ed5da11a | ||
|
|
6dcc1cb7e1 | ||
|
|
11fb2ac0fc | ||
|
|
efb34ef677 | ||
|
|
9ee5a94d50 | ||
|
|
97ef00742e | ||
|
|
4c53898382 | ||
|
|
6516d4282e | ||
|
|
d1125207b4 | ||
|
|
00f682991c |
19 changed files with 357 additions and 127 deletions
|
|
@ -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<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` |
|
||||
| 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: ...` |
|
||||
|
||||
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: <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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<serde_json::Value>,
|
||||
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> {
|
||||
) -> 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
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
@ -380,6 +389,20 @@ 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.
|
||||
|
|
@ -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<String>,
|
||||
diff: Option<String>,
|
||||
description: Option<String>,
|
||||
) {
|
||||
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<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.
|
||||
|
|
|
|||
|
|
@ -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<Coordinator>) {
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -979,9 +979,8 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
|
|||
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:#}")),
|
||||
|
|
|
|||
|
|
@ -76,6 +76,13 @@ 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
|
||||
|
|
@ -346,6 +353,7 @@ mod tests {
|
|||
sha_short: None,
|
||||
diff: None,
|
||||
description: None,
|
||||
pr_number: None,
|
||||
},
|
||||
DashboardEvent::ApprovalResolved {
|
||||
seq: 1,
|
||||
|
|
|
|||
|
|
@ -101,10 +101,32 @@ 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 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<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 scope.agents || scope.is_everything() {
|
||||
if is_broad_scope(scope) {
|
||||
for c in lifecycle::list().await? {
|
||||
let logical = c
|
||||
.strip_prefix(lifecycle::AGENT_PREFIX)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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|labels|assign|timeline> …`.
|
||||
/// Issue-scoped commands: `issue <show|create|edit|view|comment|comments|close|reopen|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|labels|assign|timeline> …`.
|
||||
/// PR-scoped commands: `pr <show|status|create|merge|reviews|commits|diff|view|comment|comments|close|reopen|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
|
||||
|
|
|
|||
|
|
@ -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/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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/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)
|
||||
|
|
|
|||
35
hive-forge/src/verbs/reopen.rs
Normal file
35
hive-forge/src/verbs/reopen.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//! `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"),
|
||||
}))
|
||||
}
|
||||
|
|
@ -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 <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 = [ ];
|
||||
|
|
@ -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}" ];
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue