Compare commits
19 changed files with 127 additions and 357 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
|
Forgejo fires notifications for the agent's own actions (it opened a
|
||||||
PR, posted a comment, submitted a review). Surfacing those would
|
PR, posted a comment, submitted a review). Surfacing those would
|
||||||
loop claude on its own writes. The comment/review case is dropped
|
loop claude on its own writes. Two filter rules drop them silently
|
||||||
silently (mark-read without delivery):
|
(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
|
- **Self-authored comments / reviews** — comment payload's
|
||||||
`user.login` matches `own_login`.
|
`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
|
`own_login` is fetched once at startup via `GET /api/v1/user`. On
|
||||||
fetch failure the filter degrades open (no filtering) rather than
|
fetch failure the filter degrades open (no filtering) rather than
|
||||||
crashing the task — a noisy inbox beats a silently-stuck poller.
|
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 |
|
| Trigger | Wrapper |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\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: ...` |
|
| 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: ...` |
|
| 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: ...` |
|
| 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: ...` |
|
| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...\nreason: subscribed` |
|
||||||
|
|
||||||
Review labels come from the Forgejo `state` field: `APPROVED` →
|
Review labels come from the Forgejo `state` field: `APPROVED` →
|
||||||
`approved`, `REQUEST_CHANGES` → `changes requested`, `COMMENT` →
|
`approved`, `REQUEST_CHANGES` → `changes requested`, `COMMENT` →
|
||||||
|
|
@ -222,6 +222,14 @@ Every wrapper ends with one or more of:
|
||||||
the line shape is stable.
|
the line shape is stable.
|
||||||
- `reviewer: <list>` — PR notifications only, present only when
|
- `reviewer: <list>` — PR notifications only, present only when
|
||||||
`requested_reviewers` is non-empty.
|
`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
|
### 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
|
refuses an issue number, which the old generic `close` couldn't). Run
|
||||||
`hive-forge pr --help` / `hive-forge issue --help` for the full subcommand
|
`hive-forge pr --help` / `hive-forge issue --help` for the full subcommand
|
||||||
list (show/create/edit/status/merge/reviews/commits/diff/view/comment/
|
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
|
The flat forms below (`close 42`, `pr-create …`, `pr-status …`, …) still work
|
||||||
as **hidden back-compat aliases** during the transition and are dropped from
|
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
|
```bash
|
||||||
hive-forge pr close 42 # close a PR (kind-validated)
|
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 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 pr status --pr 42 # PR health (mergeable / CI / reviews)
|
||||||
hive-forge issue create --title "..." --body "..."
|
hive-forge issue create --title "..." --body "..."
|
||||||
# --- flat aliases below remain valid (hidden) ---
|
# --- 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
|
- `■ ST0P` — running agents only
|
||||||
- `▶ ST4RT` — stopped agents only
|
- `▶ ST4RT` — stopped agents only
|
||||||
- `↻ R3BU1LD` — always available
|
- `↻ 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
|
- `⇡ M0V3 → ROOT` — promote selected agents to top-level
|
||||||
(parent = null); disabled when all selected are already at root.
|
(parent = null); disabled when all selected are already at root.
|
||||||
Backend `topology::set_parent` refuses moves it can't satisfy
|
Backend `topology::set_parent` refuses moves it can't satisfy
|
||||||
|
|
|
||||||
|
|
@ -314,18 +314,17 @@ export function renderApprovals() {
|
||||||
for (const a of pending) {
|
for (const a of pending) {
|
||||||
const isApply = a.kind === 'apply_commit';
|
const isApply = a.kind === 'apply_commit';
|
||||||
const isInit = a.kind === 'init_config';
|
const isInit = a.kind === 'init_config';
|
||||||
const isMergePr = a.kind === 'merge_config_pr';
|
|
||||||
const li = el('li', { class: 'approval-card' });
|
const li = el('li', { class: 'approval-card' });
|
||||||
|
|
||||||
// ── identity header ──────────────────────────────────────────
|
// ── identity header ──────────────────────────────────────────
|
||||||
const head = el('div', { class: 'approval-head' },
|
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: 'id' }, '#' + a.id),
|
||||||
el('span', { class: 'agent' }, a.agent),
|
el('span', { class: 'agent' }, a.agent),
|
||||||
el('span', { class: 'kind' + ((isApply || isMergePr) ? '' : ' kind-spawn') },
|
el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
|
||||||
isApply ? 'apply' : isMergePr ? 'merge-pr' : isInit ? 'init' : '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.
|
// When the approval was requested — relative time, right-aligned.
|
||||||
// Goes amber once it's been pending an hour so a stale request is
|
// 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).
|
// obvious at a glance (see docs/web-ui.md::Approval card).
|
||||||
|
|
@ -358,19 +357,6 @@ export function renderApprovals() {
|
||||||
}, '↳ commit on forge ↗'));
|
}, '↳ commit on forge ↗'));
|
||||||
}
|
}
|
||||||
body.append(drill);
|
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 {
|
} else {
|
||||||
body.append(el('span', { class: 'meta' },
|
body.append(el('span', { class: 'meta' },
|
||||||
isInit
|
isInit
|
||||||
|
|
@ -417,7 +403,7 @@ function renderApprovalHistory(root, history) {
|
||||||
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
|
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
|
||||||
el('span', { class: 'id' }, '#' + a.id), ' ',
|
el('span', { class: 'id' }, '#' + a.id), ' ',
|
||||||
el('span', { class: 'agent' }, a.agent), ' ',
|
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), ' ');
|
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
|
||||||
row.append(
|
row.append(
|
||||||
|
|
|
||||||
|
|
@ -249,8 +249,8 @@ window.marked = marked;
|
||||||
// ─── per-agent context menu ──────────────────────────────────────────
|
// ─── per-agent context menu ──────────────────────────────────────────
|
||||||
// Three-dot (⋮) button on each agent card for quick single-agent
|
// Three-dot (⋮) button on each agent card for quick single-agent
|
||||||
// lifecycle actions without needing to select first. State-aware:
|
// lifecycle actions without needing to select first. State-aware:
|
||||||
// restart/stop only shown when running, start only shown when
|
// restart/stop only shown when running, start only shown when stopped,
|
||||||
// stopped; rebuild + destroy/purge always shown.
|
// destroy/purge hidden for the manager.
|
||||||
// The button is CSS-invisible until the row is hovered (or menu is
|
// The button is CSS-invisible until the row is hovered (or menu is
|
||||||
// open) so it doesn't clutter quiet rows.
|
// open) so it doesn't clutter quiet rows.
|
||||||
|
|
||||||
|
|
@ -383,18 +383,20 @@ window.marked = marked;
|
||||||
`view ${c.name} journal logs`),
|
`view ${c.name} journal logs`),
|
||||||
);
|
);
|
||||||
|
|
||||||
dropdown.append(
|
{
|
||||||
menuSep(),
|
dropdown.append(
|
||||||
menuItem('DESTR0Y', {
|
menuSep(),
|
||||||
action: '/api/destroy/',
|
menuItem('DESTR0Y', {
|
||||||
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
|
action: '/api/destroy/',
|
||||||
}),
|
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
|
||||||
menuItem('PURG3', {
|
}),
|
||||||
action: '/api/destroy/',
|
menuItem('PURG3', {
|
||||||
body: { purge: 'on' },
|
action: '/api/destroy/',
|
||||||
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
|
body: { purge: 'on' },
|
||||||
}),
|
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
|
||||||
);
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (c.deployed_sha && forgeBase) {
|
if (c.deployed_sha && forgeBase) {
|
||||||
const li = el('li', { role: 'presentation' });
|
const li = el('li', { role: 'presentation' });
|
||||||
|
|
|
||||||
|
|
@ -382,7 +382,8 @@ async fn format_notification(
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_pr = matches!(notif_type, "Pull Request" | "Pull");
|
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
|
// Determine whether this notification was triggered by a comment/review or
|
||||||
// by creation/state-change of the subject itself.
|
// by creation/state-change of the subject itself.
|
||||||
|
|
@ -395,6 +396,7 @@ async fn format_notification(
|
||||||
num,
|
num,
|
||||||
repo,
|
repo,
|
||||||
meta_suffix,
|
meta_suffix,
|
||||||
|
reason,
|
||||||
subject,
|
subject,
|
||||||
is_pr,
|
is_pr,
|
||||||
};
|
};
|
||||||
|
|
@ -409,7 +411,7 @@ async fn format_notification(
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else {
|
} 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,
|
num: String,
|
||||||
repo: String,
|
repo: String,
|
||||||
meta_suffix: 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.
|
/// Fetched subject detail (issue/PR JSON); used for review-request detection.
|
||||||
subject: Option<serde_json::Value>,
|
subject: Option<serde_json::Value>,
|
||||||
is_pr: bool,
|
is_pr: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...`)
|
/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...` and
|
||||||
/// suffix appended to every wrapper. Shape + presence rules live in
|
/// `\nreason: ...`) suffix appended to every wrapper. Shape +
|
||||||
/// `docs/forge.md::Meta suffix`.
|
/// presence rules live in `docs/forge.md::Meta suffix`.
|
||||||
fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool) -> String {
|
fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: &str) -> String {
|
||||||
let assignees: Vec<&str> = subject
|
let assignees: Vec<&str> = subject
|
||||||
.and_then(|s| s["assignees"].as_array())
|
.and_then(|s| s["assignees"].as_array())
|
||||||
.map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect())
|
.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 {
|
} else {
|
||||||
None
|
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}");
|
let mut out = format!("\n{assignee_line}");
|
||||||
if let Some(r) = reviewer_line {
|
if let Some(r) = reviewer_line {
|
||||||
write!(out, "\n{r}").ok();
|
write!(out, "\n{r}").ok();
|
||||||
}
|
}
|
||||||
|
if let Some(r) = reason_line {
|
||||||
|
write!(out, "\n{r}").ok();
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -557,7 +574,7 @@ fn format_state_change_notification(
|
||||||
notif: &serde_json::Value,
|
notif: &serde_json::Value,
|
||||||
meta: &NotifMeta<'_>,
|
meta: &NotifMeta<'_>,
|
||||||
own_login: &str,
|
own_login: &str,
|
||||||
) -> String {
|
) -> Option<String> {
|
||||||
// Classification uses notif["subject"]["state"] directly — Forgejo
|
// Classification uses notif["subject"]["state"] directly — Forgejo
|
||||||
// returns "open" / "closed" / "merged" here. We do NOT rely on
|
// returns "open" / "closed" / "merged" here. We do NOT rely on
|
||||||
// fetching the PR/issue detail for `merged`:
|
// 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".
|
// - Forgejo API type is "Pull" / "Issue", never "Pull Request".
|
||||||
let notif_state = notif["subject"]["state"].as_str().unwrap_or("");
|
let notif_state = notif["subject"]["state"].as_str().unwrap_or("");
|
||||||
|
|
||||||
// "New" = the subject is open (or state is absent). Used below for
|
// Self-notification filter: drop new items we authored ourselves
|
||||||
// the review-request override.
|
// (`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();
|
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 {
|
let NotifMeta {
|
||||||
title,
|
title,
|
||||||
|
|
@ -577,6 +599,7 @@ fn format_state_change_notification(
|
||||||
num,
|
num,
|
||||||
repo,
|
repo,
|
||||||
meta_suffix,
|
meta_suffix,
|
||||||
|
reason: _,
|
||||||
subject,
|
subject,
|
||||||
is_pr,
|
is_pr,
|
||||||
} = meta;
|
} = meta;
|
||||||
|
|
@ -635,7 +658,7 @@ fn format_state_change_notification(
|
||||||
|
|
||||||
let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}");
|
let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}");
|
||||||
out.push_str(meta_suffix);
|
out.push_str(meta_suffix);
|
||||||
out
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide whether a state-change notification represents the subject's
|
/// Decide whether a state-change notification represents the subject's
|
||||||
|
|
|
||||||
|
|
@ -131,15 +131,6 @@ pub struct Coordinator {
|
||||||
/// agent is in this set — the inbound fence. Cleared when the agent
|
/// agent is in this set — the inbound fence. Cleared when the agent
|
||||||
/// reports `GracefulStopComplete` or the container is stopped.
|
/// reports `GracefulStopComplete` or the container is stopped.
|
||||||
graceful_stop_pending: Mutex<HashSet<String>>,
|
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
|
/// Unified wire-facing event channel feeding the dashboard SSE
|
||||||
/// stream. Carries broker messages (mirrored from `broker.subscribe`
|
/// stream. Carries broker messages (mirrored from `broker.subscribe`
|
||||||
/// by the forwarder task in `main.rs`) and dashboard-only mutation
|
/// by the forwarder task in `main.rs`) and dashboard-only mutation
|
||||||
|
|
@ -389,20 +380,6 @@ pub struct ApprovalResolved<'a> {
|
||||||
pub description: Option<String>,
|
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`].
|
/// Field-named payload for [`Coordinator::emit_question_added`].
|
||||||
/// Mirrors the `QuestionAdded` dashboard-event fields; all references
|
/// Mirrors the `QuestionAdded` dashboard-event fields; all references
|
||||||
/// share the caller's lifetime.
|
/// share the caller's lifetime.
|
||||||
|
|
@ -480,7 +457,6 @@ impl Coordinator {
|
||||||
recent_transient: Mutex::new(HashMap::new()),
|
recent_transient: Mutex::new(HashMap::new()),
|
||||||
recent_crashes: Mutex::new(HashMap::new()),
|
recent_crashes: Mutex::new(HashMap::new()),
|
||||||
graceful_stop_pending: Mutex::new(HashSet::new()),
|
graceful_stop_pending: Mutex::new(HashSet::new()),
|
||||||
last_stopped_running: Mutex::new(None),
|
|
||||||
dashboard_events,
|
dashboard_events,
|
||||||
event_seq: AtomicU64::new(0),
|
event_seq: AtomicU64::new(0),
|
||||||
meta_updates_active: AtomicU64::new(0),
|
meta_updates_active: AtomicU64::new(0),
|
||||||
|
|
@ -754,16 +730,15 @@ impl Coordinator {
|
||||||
/// Emit `ApprovalAdded` immediately after the row is inserted in
|
/// Emit `ApprovalAdded` immediately after the row is inserted in
|
||||||
/// sqlite. Caller passes the diff text it already computed (or
|
/// sqlite. Caller passes the diff text it already computed (or
|
||||||
/// `None` for spawn approvals which carry no diff).
|
/// `None` for spawn approvals which carry no diff).
|
||||||
pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) {
|
pub fn emit_approval_added(
|
||||||
let ApprovalAdded {
|
&self,
|
||||||
id,
|
id: i64,
|
||||||
agent,
|
agent: &str,
|
||||||
approval_kind,
|
approval_kind: &'static str,
|
||||||
sha_short,
|
sha_short: Option<String>,
|
||||||
diff,
|
diff: Option<String>,
|
||||||
description,
|
description: Option<String>,
|
||||||
pr_number,
|
) {
|
||||||
} = ev;
|
|
||||||
self.emit_dashboard_event(DashboardEvent::ApprovalAdded {
|
self.emit_dashboard_event(DashboardEvent::ApprovalAdded {
|
||||||
seq: self.next_seq(),
|
seq: self.next_seq(),
|
||||||
id,
|
id,
|
||||||
|
|
@ -772,7 +747,6 @@ impl Coordinator {
|
||||||
sha_short,
|
sha_short,
|
||||||
diff,
|
diff,
|
||||||
description,
|
description,
|
||||||
pr_number,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1183,20 +1157,6 @@ impl Coordinator {
|
||||||
self.graceful_stop_pending.lock().unwrap().remove(name);
|
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
|
/// Set of agents whose transient was cleared within the last
|
||||||
/// `grace` seconds — i.e. agents the operator just acted on,
|
/// `grace` seconds — i.e. agents the operator just acted on,
|
||||||
/// whose stop the crash watcher should NOT classify as a crash.
|
/// 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::container_view::claude_has_session;
|
||||||
use crate::coordinator::{Coordinator, TransientKind};
|
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);
|
const POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
|
@ -37,11 +37,15 @@ pub fn spawn(coord: Arc<Coordinator>) {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let logical = logical.to_owned();
|
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 {
|
if lifecycle::is_running(&logical).await {
|
||||||
current_running.insert(logical.clone());
|
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());
|
current_logged_in.insert(logical.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -979,8 +979,9 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State-dir names that don't appear in the live container list. Each
|
/// State-dir names that don't appear in the live container list (and
|
||||||
/// one surfaces in the dashboard as a row with R3V1V3 + PURG3 actions.
|
/// aren't the manager). Each one surfaces in the dashboard as a row
|
||||||
|
/// with R3V1V3 + PURG3 actions.
|
||||||
fn build_tombstone_views(
|
fn build_tombstone_views(
|
||||||
coord: &Coordinator,
|
coord: &Coordinator,
|
||||||
containers: &[ContainerView],
|
containers: &[ContainerView],
|
||||||
|
|
@ -994,7 +995,7 @@ fn build_tombstone_views(
|
||||||
.collect();
|
.collect();
|
||||||
Coordinator::kept_state_names()
|
Coordinator::kept_state_names()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|name| !live.contains(name.as_str()))
|
.filter(|name| name != MANAGER_NAME && !live.contains(name.as_str()))
|
||||||
.map(|name| {
|
.map(|name| {
|
||||||
let root = Coordinator::agent_state_root(&name);
|
let root = Coordinator::agent_state_root(&name);
|
||||||
let state_bytes = dir_size_bytes(&root);
|
let state_bytes = dir_size_bytes(&root);
|
||||||
|
|
@ -1662,15 +1663,7 @@ async fn post_request_spawn(
|
||||||
// refetch. Spawn approvals carry no diff/sha.
|
// refetch. Spawn approvals carry no diff/sha.
|
||||||
state
|
state
|
||||||
.coord
|
.coord
|
||||||
.emit_approval_added(crate::coordinator::ApprovalAdded {
|
.emit_approval_added(id, &name, "spawn", None, None, None);
|
||||||
id,
|
|
||||||
agent: &name,
|
|
||||||
approval_kind: "spawn",
|
|
||||||
sha_short: None,
|
|
||||||
diff: None,
|
|
||||||
description: None,
|
|
||||||
pr_number: None,
|
|
||||||
});
|
|
||||||
(StatusCode::OK, "ok").into_response()
|
(StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
|
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
|
||||||
|
|
|
||||||
|
|
@ -76,13 +76,6 @@ pub enum DashboardEvent {
|
||||||
sha_short: Option<String>,
|
sha_short: Option<String>,
|
||||||
diff: Option<String>,
|
diff: Option<String>,
|
||||||
description: 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
|
/// A pending approval transitioned to a terminal state
|
||||||
/// (approved / denied / failed). Clients move the row out of the
|
/// (approved / denied / failed). Clients move the row out of the
|
||||||
|
|
@ -353,7 +346,6 @@ mod tests {
|
||||||
sha_short: None,
|
sha_short: None,
|
||||||
diff: None,
|
diff: None,
|
||||||
description: None,
|
description: None,
|
||||||
pr_number: None,
|
|
||||||
},
|
},
|
||||||
DashboardEvent::ApprovalResolved {
|
DashboardEvent::ApprovalResolved {
|
||||||
seq: 1,
|
seq: 1,
|
||||||
|
|
|
||||||
|
|
@ -101,32 +101,10 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
// graceful-stop queue, to re-expand it).
|
// graceful-stop queue, to re-expand it).
|
||||||
let agents = scoped_agents(scope).await?;
|
let agents = scoped_agents(scope).await?;
|
||||||
let infra = scoped_infra(scope);
|
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?
|
handle_stop(&coord, &agents, &infra, *graceful).await?
|
||||||
}
|
}
|
||||||
HostRequest::Start { scope } => {
|
HostRequest::Start { scope } => {
|
||||||
let mut agents = scoped_agents(scope).await?;
|
let 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);
|
let infra = scoped_infra(scope);
|
||||||
handle_start(&agents, &infra).await?
|
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
|
/// container (from `lifecycle::list`) when `agents` is set or the scope is
|
||||||
/// "everything", plus any explicit `agent_names`. Returns de-duplicated
|
/// "everything", plus any explicit `agent_names`. Returns de-duplicated
|
||||||
/// logical names with the `h-` container prefix stripped.
|
/// 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>> {
|
async fn scoped_agents(scope: &LifecycleScope) -> Result<Vec<String>> {
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
let mut set: BTreeSet<String> = BTreeSet::new();
|
let mut set: BTreeSet<String> = BTreeSet::new();
|
||||||
if is_broad_scope(scope) {
|
if scope.agents || scope.is_everything() {
|
||||||
for c in lifecycle::list().await? {
|
for c in lifecycle::list().await? {
|
||||||
let logical = c
|
let logical = c
|
||||||
.strip_prefix(lifecycle::AGENT_PREFIX)
|
.strip_prefix(lifecycle::AGENT_PREFIX)
|
||||||
|
|
|
||||||
|
|
@ -1438,15 +1438,14 @@ fn handle_request_update_meta_inputs(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tracing::info!(%id, %label, "update_meta_inputs approval queued");
|
tracing::info!(%id, %label, "update_meta_inputs approval queued");
|
||||||
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
coord.emit_approval_added(
|
||||||
id,
|
id,
|
||||||
agent: requester,
|
requester,
|
||||||
approval_kind: "update_meta_inputs",
|
"update_meta_inputs",
|
||||||
sha_short: None,
|
None,
|
||||||
diff: None,
|
None,
|
||||||
description: description.map(str::to_owned),
|
description.map(str::to_owned),
|
||||||
pr_number: None,
|
);
|
||||||
});
|
|
||||||
AgentResponse::Ok
|
AgentResponse::Ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1543,15 +1542,14 @@ fn handle_request_schedule_prompt(
|
||||||
interval = ?payload.interval_seconds,
|
interval = ?payload.interval_seconds,
|
||||||
"schedule_prompt approval queued"
|
"schedule_prompt approval queued"
|
||||||
);
|
);
|
||||||
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
coord.emit_approval_added(
|
||||||
id,
|
id,
|
||||||
agent: requester,
|
requester,
|
||||||
approval_kind: "schedule_prompt",
|
"schedule_prompt",
|
||||||
sha_short: None,
|
None,
|
||||||
diff: None,
|
None,
|
||||||
description: payload.description.clone(),
|
payload.description.clone(),
|
||||||
pr_number: None,
|
);
|
||||||
});
|
|
||||||
AgentResponse::Ok
|
AgentResponse::Ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1804,15 +1802,7 @@ pub(crate) fn submit_init_config(
|
||||||
)
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
|
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
|
||||||
tracing::info!(%id, %name, "init_config approval queued");
|
tracing::info!(%id, %name, "init_config approval queued");
|
||||||
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
coord.emit_approval_added(id, name, "init_config", None, None, description);
|
||||||
id,
|
|
||||||
agent: name,
|
|
||||||
approval_kind: "init_config",
|
|
||||||
sha_short: None,
|
|
||||||
diff: None,
|
|
||||||
description,
|
|
||||||
pr_number: None,
|
|
||||||
});
|
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1938,15 +1928,14 @@ pub(crate) async fn submit_apply_commit(
|
||||||
// get a fully-formed row without a snapshot refetch. `sha_short`
|
// get a fully-formed row without a snapshot refetch. `sha_short`
|
||||||
// is reused from the dedup gate above.
|
// is reused from the dedup gate above.
|
||||||
let diff = crate::dashboard::approval_diff(agent, id).await;
|
let diff = crate::dashboard::approval_diff(agent, id).await;
|
||||||
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
coord.emit_approval_added(
|
||||||
id,
|
id,
|
||||||
agent,
|
agent,
|
||||||
approval_kind: "apply_commit",
|
"apply_commit",
|
||||||
sha_short: Some(sha_short),
|
Some(sha_short),
|
||||||
diff: Some(diff),
|
Some(diff),
|
||||||
description: description.map(str::to_owned),
|
description.map(str::to_owned),
|
||||||
pr_number: None,
|
);
|
||||||
});
|
|
||||||
Ok((id, sha))
|
Ok((id, sha))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -315,9 +315,8 @@ pub fn apply_add_child(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconcile `topology.json` against the current agent set. Adds an
|
/// Reconcile `topology.json` against the current agent set. Adds an
|
||||||
/// entry (default: parent = null — a new agent with no declared parent
|
/// entry (default: parent = manager, manager itself = root) for any
|
||||||
/// is its own root) for any agent missing from the file; removes
|
/// agent missing from the file; removes entries for agents no longer
|
||||||
/// entries for agents no longer
|
|
||||||
/// present. Existing entries are preserved as-is — operator/manager
|
/// present. Existing entries are preserved as-is — operator/manager
|
||||||
/// choices stick across regenerations. Returns true when the file
|
/// choices stick across regenerations. Returns true when the file
|
||||||
/// changed and should be re-committed by the caller.
|
/// 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.
|
/// Dump title + body + all comments for an issue or PR.
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
View(verbs::view::Args),
|
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),
|
Issue(verbs::issue_cmd::Args),
|
||||||
/// Create an issue. Prints the issue URL on success.
|
/// Create an issue. Prints the issue URL on success.
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
|
|
@ -63,7 +63,7 @@ enum Verb {
|
||||||
/// Edit an issue's title, body, state, or milestone.
|
/// Edit an issue's title, body, state, or milestone.
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
IssueEdit(verbs::issue_edit::Args),
|
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),
|
Pr(verbs::pr_cmd::Args),
|
||||||
/// List a PR's commits as JSON (sha, message, author date, author).
|
/// List a PR's commits as JSON (sha, message, author date, author).
|
||||||
/// Survives rebase-rewritten shas — message + author date let a
|
/// Survives rebase-rewritten shas — message + author date let a
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! `issue <verb>` — issue-scoped sub-commands. Wraps the per-verb modules
|
//! `issue <verb>` — issue-scoped sub-commands. Wraps the per-verb modules
|
||||||
//! under an `issue` parent so `hive-forge issue close 42`, `issue create …`,
|
//! 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
|
//! 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
|
//! number is an issue first (`assert_kind`); the issue-only verbs are
|
||||||
//! kind-correct by construction. The flat `issue-*` + bare generic verbs stay
|
//! kind-correct by construction. The flat `issue-*` + bare generic verbs stay
|
||||||
//! as hidden back-compat aliases (see `main.rs`).
|
//! as hidden back-compat aliases (see `main.rs`).
|
||||||
|
|
@ -34,8 +34,6 @@ enum Cmd {
|
||||||
Comments(verbs::comments::Args),
|
Comments(verbs::comments::Args),
|
||||||
/// Close the issue.
|
/// Close the issue.
|
||||||
Close(verbs::close::Args),
|
Close(verbs::close::Args),
|
||||||
/// Reopen a closed issue.
|
|
||||||
Reopen(verbs::reopen::Args),
|
|
||||||
/// List / add / remove labels.
|
/// List / add / remove labels.
|
||||||
Labels(verbs::labels::Args),
|
Labels(verbs::labels::Args),
|
||||||
/// Assign or unassign a user.
|
/// Assign or unassign a user.
|
||||||
|
|
@ -67,10 +65,6 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
assert_kind(client, a.number, Kind::Issue)?;
|
assert_kind(client, a.number, Kind::Issue)?;
|
||||||
verbs::close::run(client, a)
|
verbs::close::run(client, a)
|
||||||
}
|
}
|
||||||
Cmd::Reopen(a) => {
|
|
||||||
assert_kind(client, a.number, Kind::Issue)?;
|
|
||||||
verbs::reopen::run(client, a)
|
|
||||||
}
|
|
||||||
Cmd::Labels(a) => {
|
Cmd::Labels(a) => {
|
||||||
assert_kind(client, a.number, Kind::Issue)?;
|
assert_kind(client, a.number, Kind::Issue)?;
|
||||||
verbs::labels::run(client, a)
|
verbs::labels::run(client, a)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ pub mod pr_create;
|
||||||
pub mod pr_merge;
|
pub mod pr_merge;
|
||||||
pub mod pr_reviews;
|
pub mod pr_reviews;
|
||||||
pub mod pr_status;
|
pub mod pr_status;
|
||||||
pub mod reopen;
|
|
||||||
pub mod repo_add_collaborator;
|
pub mod repo_add_collaborator;
|
||||||
pub mod repo_create;
|
pub mod repo_create;
|
||||||
pub mod repo_labels;
|
pub mod repo_labels;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! `pr <verb>` — PR-scoped sub-commands. Wraps the per-verb modules under a
|
//! `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
|
//! `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
|
//! 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
|
//! 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
|
//! kind-correct by construction. The flat `pr-*` + bare generic verbs stay as
|
||||||
//! hidden back-compat aliases (see `main.rs`).
|
//! hidden back-compat aliases (see `main.rs`).
|
||||||
|
|
@ -42,8 +42,6 @@ enum Cmd {
|
||||||
Comments(verbs::comments::Args),
|
Comments(verbs::comments::Args),
|
||||||
/// Close the PR.
|
/// Close the PR.
|
||||||
Close(verbs::close::Args),
|
Close(verbs::close::Args),
|
||||||
/// Reopen a closed PR.
|
|
||||||
Reopen(verbs::reopen::Args),
|
|
||||||
/// List / add / remove labels.
|
/// List / add / remove labels.
|
||||||
Labels(verbs::labels::Args),
|
Labels(verbs::labels::Args),
|
||||||
/// Assign or unassign a user.
|
/// Assign or unassign a user.
|
||||||
|
|
@ -79,10 +77,6 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
assert_kind(client, a.number, Kind::Pr)?;
|
assert_kind(client, a.number, Kind::Pr)?;
|
||||||
verbs::close::run(client, a)
|
verbs::close::run(client, a)
|
||||||
}
|
}
|
||||||
Cmd::Reopen(a) => {
|
|
||||||
assert_kind(client, a.number, Kind::Pr)?;
|
|
||||||
verbs::reopen::run(client, a)
|
|
||||||
}
|
|
||||||
Cmd::Labels(a) => {
|
Cmd::Labels(a) => {
|
||||||
assert_kind(client, a.number, Kind::Pr)?;
|
assert_kind(client, a.number, Kind::Pr)?;
|
||||||
verbs::labels::run(client, a)
|
verbs::labels::run(client, a)
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
@ -174,71 +174,6 @@ in
|
||||||
visible = false;
|
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 {
|
options.hyperhive.allowedRecipients = lib.mkOption {
|
||||||
type = lib.types.listOf lib.types.str;
|
type = lib.types.listOf lib.types.str;
|
||||||
default = [ ];
|
default = [ ];
|
||||||
|
|
@ -787,11 +722,6 @@ in
|
||||||
'';
|
'';
|
||||||
|
|
||||||
assertions = [
|
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
|
# Guard the inputs-routed-as-output pattern: the agent flake.nix is
|
||||||
# expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`.
|
# expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`.
|
||||||
# If `self` leaks into flakeInputs the agent gets a spurious attrset
|
# If `self` leaks into flakeInputs the agent gets a spurious attrset
|
||||||
|
|
@ -1710,37 +1640,6 @@ in
|
||||||
systemd.services.hive-ag3nt =
|
systemd.services.hive-ag3nt =
|
||||||
let
|
let
|
||||||
binary = "hive";
|
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
|
in
|
||||||
{
|
{
|
||||||
description = "${binary} harness";
|
description = "${binary} harness";
|
||||||
|
|
@ -1763,10 +1662,9 @@ in
|
||||||
# `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle
|
# `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle
|
||||||
# bind-mounts and gateway upstream config stay in sync.
|
# bind-mounts and gateway upstream config stay in sync.
|
||||||
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
|
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
|
||||||
}
|
};
|
||||||
// otelEnv;
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = if otel.enable then "${otelExecStart}" else "${pkgs.hyperhive}/bin/${binary} serve";
|
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 2;
|
RestartSec = 2;
|
||||||
# Per-service runtime dir owned by `User=` below; the harness
|
# Per-service runtime dir owned by `User=` below; the harness
|
||||||
|
|
@ -1776,9 +1674,6 @@ in
|
||||||
RuntimeDirectory = "hive-config";
|
RuntimeDirectory = "hive-config";
|
||||||
User = userName;
|
User = userName;
|
||||||
Group = userName;
|
Group = userName;
|
||||||
}
|
|
||||||
// lib.optionalAttrs (otel.enable && otel.headersCredential != null) {
|
|
||||||
LoadCredential = [ "otel-headers:${otel.headersCredential}" ];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue