Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e89ca90956 | ||
|
|
50e0ef9b46 | ||
|
|
564417a6da | ||
|
|
986b3df9b0 | ||
|
|
bd9140ed20 | ||
|
|
1ee48989f2 | ||
|
|
aa86af845a | ||
|
|
2157c3ae01 | ||
|
|
5f57ea4ef1 | ||
|
|
ab1b07acce |
32 changed files with 397 additions and 72 deletions
|
|
@ -480,15 +480,20 @@ re-renders the terminal row. Manager is addressed as `@root`.
|
||||||
## H0M3 page (`/`)
|
## H0M3 page (`/`)
|
||||||
|
|
||||||
The H0M3 hub is the primary landing page (served at `/` by default). A
|
The H0M3 hub is the primary landing page (served at `/` by default). A
|
||||||
responsive grid of link tiles — Dashboard, Flow, Logs, Matrix (when enabled)
|
responsive grid of link tiles — Dashboard, Flow, Logs, Matrix (when enabled),
|
||||||
— each pointing to their respective surfaces. The page is a pure portal with
|
Forge (when enabled) — each pointing to their respective surfaces. The page
|
||||||
no tab-bar or SSE subscriptions. Typography + colours inherit from the shared
|
is a pure portal with no tab-bar or SSE subscriptions. Typography + colours
|
||||||
theme (Catppuccin Mocha via `common.css` + `theme.css`). The Matrix tile is
|
inherit from the shared theme (Catppuccin Mocha via `common.css` + `theme.css`).
|
||||||
hidden until `home.js` confirms `matrix_gui_enabled` (same gating as the
|
Optional tiles are hidden until `home.js` confirms their availability:
|
||||||
dashboard's M4TR1X tab); `home.js` also fills the swarm/hive identity line
|
Matrix is hidden until `home.js` confirms `matrix_gui_enabled` (same gating as
|
||||||
at the top. Dashboard is now served at `/dashboard.html` (route swap completed
|
the dashboard's M4TR1X tab); Forge is hidden until `home.js` confirms
|
||||||
in #1464 step 2); the home page at `/` replaces the old dashboard root. All
|
`state.forge_present` and fills the href from `state.forge_public_url` (the
|
||||||
dashboard sub-pages include a `← Home` back-link for navigation.
|
gateway-served public URL when `services.hyperhive.forge.behindGateway=true`)
|
||||||
|
or falls back to the direct `:3000` port. Operators without matrix or forge
|
||||||
|
enabled never see dead links. `home.js` also fills the swarm/hive identity
|
||||||
|
line at the top. Dashboard is now served at `/dashboard.html` (route swap
|
||||||
|
completed in #1464 step 2); the home page at `/` replaces the old dashboard
|
||||||
|
root. All dashboard sub-pages include a `← Home` back-link for navigation.
|
||||||
|
|
||||||
## L0GS page (`/logs.html`)
|
## L0GS page (`/logs.html`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -756,6 +756,16 @@ window.marked = marked;
|
||||||
const h = Math.floor(m / 60);
|
const h = Math.floor(m / 60);
|
||||||
return h + 'h ' + (m % 60) + 'm';
|
return h + 'h ' + (m % 60) + 'm';
|
||||||
}
|
}
|
||||||
|
// Wall-clock HH:MM:SS (UTC, matching the inbox timestamps on this page)
|
||||||
|
// from a unix-seconds value. Used to label turn-start / turn-end rows
|
||||||
|
// when the event carries a `ts` (see the turn renderers below).
|
||||||
|
function fmtClock(sec) {
|
||||||
|
return new Date(sec * 1000).toISOString().slice(11, 19);
|
||||||
|
}
|
||||||
|
// Unix-seconds stamp of the most recent open turn-start, so the
|
||||||
|
// matching turn-end can show a duration. Turns are sequential, so a
|
||||||
|
// single slot is enough (history replays chronologically too).
|
||||||
|
let pendingTurnStartTs = null;
|
||||||
const STATE_TOOLTIPS = {
|
const STATE_TOOLTIPS = {
|
||||||
loading: 'harness not yet contacted',
|
loading: 'harness not yet contacted',
|
||||||
offline: 'harness unreachable or claude not logged in',
|
offline: 'harness unreachable or claude not logged in',
|
||||||
|
|
@ -1697,6 +1707,16 @@ window.marked = marked;
|
||||||
if (api.fromHistory) openTurnsFromHistory += 1;
|
if (api.fromHistory) openTurnsFromHistory += 1;
|
||||||
else { setBannerActive(true); setState('thinking'); }
|
else { setBannerActive(true); setState('thinking'); }
|
||||||
const block = api.row('turn-start', '◆ TURN ← ' + ev.from);
|
const block = api.row('turn-start', '◆ TURN ← ' + ev.from);
|
||||||
|
// Turn start time. Guarded on a numeric `ts` (unix seconds)
|
||||||
|
// so the row degrades to its old text-only form until the
|
||||||
|
// backend surfaces per-event timestamps.
|
||||||
|
if (typeof ev.ts === 'number') {
|
||||||
|
pendingTurnStartTs = ev.ts;
|
||||||
|
const t = document.createElement('span');
|
||||||
|
t.className = 'turn-time';
|
||||||
|
t.textContent = '· ' + fmtClock(ev.ts);
|
||||||
|
block.appendChild(t);
|
||||||
|
}
|
||||||
if (ev.unread > 0) {
|
if (ev.unread > 0) {
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'unread-badge';
|
badge.className = 'unread-badge';
|
||||||
|
|
@ -1717,9 +1737,23 @@ window.marked = marked;
|
||||||
refreshLooseEnds();
|
refreshLooseEnds();
|
||||||
}
|
}
|
||||||
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
||||||
api.row(cls,
|
const row = api.row(cls,
|
||||||
(ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail')
|
(ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail')
|
||||||
+ (ev.note ? ' — ' + ev.note : ''));
|
+ (ev.note ? ' — ' + ev.note : ''));
|
||||||
|
// Turn end time + duration since the paired turn-start.
|
||||||
|
// Same `ts` guard as turn_start; duration only when we saw the
|
||||||
|
// matching start's stamp.
|
||||||
|
if (typeof ev.ts === 'number') {
|
||||||
|
const t = document.createElement('span');
|
||||||
|
t.className = 'turn-time';
|
||||||
|
let label = '· ' + fmtClock(ev.ts);
|
||||||
|
if (pendingTurnStartTs != null && ev.ts >= pendingTurnStartTs) {
|
||||||
|
label += ' · ' + fmtAge((ev.ts - pendingTurnStartTs) * 1000);
|
||||||
|
}
|
||||||
|
t.textContent = label;
|
||||||
|
row.appendChild(t);
|
||||||
|
}
|
||||||
|
pendingTurnStartTs = null;
|
||||||
},
|
},
|
||||||
note(ev, api) {
|
note(ev, api) {
|
||||||
const t = String(ev.text || '');
|
const t = String(ev.text || '');
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,18 @@ async function init() {
|
||||||
if (tile) tile.hidden = false;
|
if (tile) tile.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forge tile: reveal + point at the live forge only when the
|
||||||
|
// hive-forge container is up. Prefer the gateway-served public URL
|
||||||
|
// (set when forge.behindGateway=true), fall back to the direct :3000
|
||||||
|
// port — same precedence the dashboard uses for forge links.
|
||||||
|
if (state.forge_present) {
|
||||||
|
const tile = $('home-tile-forge');
|
||||||
|
if (tile) {
|
||||||
|
tile.href = state.forge_public_url || `http://${location.hostname}:3000`;
|
||||||
|
tile.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ident = $('hive-identity');
|
const ident = $('hive-identity');
|
||||||
if (ident && (state.swarm_name || state.hive_name)) {
|
if (ident && (state.swarm_name || state.hive_name)) {
|
||||||
const parts = [state.swarm_name, state.hive_name].filter(Boolean);
|
const parts = [state.swarm_name, state.hive_name].filter(Boolean);
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,18 @@
|
||||||
<span class="home-tile-desc">operator-local prefs · browser notifications</span>
|
<span class="home-tile-desc">operator-local prefs · browser notifications</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Forge tile: hidden until home.js confirms the hive-forge
|
||||||
|
container is up (state.forge_present); home.js also fills the
|
||||||
|
href from state.forge_public_url (or the :3000 fallback), so
|
||||||
|
operators without a forge never see a dead link. -->
|
||||||
|
<a class="home-tile" id="home-tile-forge" href="#" target="_blank" rel="noopener" hidden>
|
||||||
|
<span class="home-tile-head">
|
||||||
|
<span class="home-tile-icon" aria-hidden="true">⚒</span>
|
||||||
|
<span class="home-tile-label">Forge</span>
|
||||||
|
</span>
|
||||||
|
<span class="home-tile-desc">issues · pull requests · agent-config repos</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Matrix tile: hidden until home.js confirms the matrix GUI is
|
<!-- Matrix tile: hidden until home.js confirms the matrix GUI is
|
||||||
enabled (state.matrix_gui_enabled), mirroring the dashboard
|
enabled (state.matrix_gui_enabled), mirroring the dashboard
|
||||||
tab gating so operators without it don't see a dead link. -->
|
tab gating so operators without it don't see a dead link. -->
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,10 @@
|
||||||
.live .row .md, .live .row > details { text-indent: 0; }
|
.live .row .md, .live .row > details { text-indent: 0; }
|
||||||
.live .turn-end-ok { color: var(--green); border-left-color: var(--green); }
|
.live .turn-end-ok { color: var(--green); border-left-color: var(--green); }
|
||||||
.live .turn-end-fail { color: var(--red); border-left-color: var(--red); }
|
.live .turn-end-fail { color: var(--red); border-left-color: var(--red); }
|
||||||
|
/* Wall-clock time (+ duration on turn-end) appended to the turn-start /
|
||||||
|
turn-end rows. Dim + smaller so the boundary glyph stays the focus and
|
||||||
|
the timestamp reads as metadata. */
|
||||||
|
.live .turn-time { color: var(--muted); font-size: 0.85em; margin-left: 0.5em; }
|
||||||
.live .text { color: var(--fg); }
|
.live .text { color: var(--fg); }
|
||||||
.live .thinking { color: var(--muted); font-style: italic; }
|
.live .thinking { color: var(--muted); font-style: italic; }
|
||||||
.live .tool-use { color: var(--cyan); }
|
.live .tool-use { color: var(--cyan); }
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,23 @@ CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts);
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct BusEvent {
|
pub struct BusEvent {
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
|
/// Unix seconds at emit time. Serialized as a sibling of the `kind`
|
||||||
|
/// tag so the agent terminal can render turn start/end times (and
|
||||||
|
/// turn duration) on the live stream; history rows carry the same
|
||||||
|
/// `ts` field sourced from the persisted `events.ts` column, so the
|
||||||
|
/// renderer reads `ts` identically for live + scrollback.
|
||||||
|
pub ts: i64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub event: LiveEvent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A persisted event paired with its stored unix-seconds timestamp.
|
||||||
|
/// Serializes with `ts` as a sibling of the `kind` tag — same wire shape
|
||||||
|
/// as a live [`BusEvent`] minus `seq` — so the agent terminal reads `ts`
|
||||||
|
/// identically whether an event arrives live or is replayed from history.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct StoredEvent {
|
||||||
|
pub ts: i64,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub event: LiveEvent,
|
pub event: LiveEvent,
|
||||||
}
|
}
|
||||||
|
|
@ -281,7 +298,7 @@ impl EventStore {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recent(&self, limit: usize) -> rusqlite::Result<Vec<LiveEvent>> {
|
fn recent(&self, limit: usize) -> rusqlite::Result<Vec<StoredEvent>> {
|
||||||
let (events, _, _) = self.page(None, limit)?;
|
let (events, _, _) = self.page(None, limit)?;
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
@ -293,43 +310,52 @@ impl EventStore {
|
||||||
&self,
|
&self,
|
||||||
before_id: Option<i64>,
|
before_id: Option<i64>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> rusqlite::Result<(Vec<LiveEvent>, Option<i64>, bool)> {
|
) -> rusqlite::Result<(Vec<StoredEvent>, Option<i64>, bool)> {
|
||||||
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
|
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
// Fetch one extra row so we can tell whether more exist.
|
// Fetch one extra row so we can tell whether more exist.
|
||||||
let fetch = limit_i.saturating_add(1);
|
let fetch = limit_i.saturating_add(1);
|
||||||
let rows: Vec<(i64, LiveEvent)> = if let Some(bid) = before_id {
|
// `ts` is the persisted emit-time unix-seconds stamp; carried out
|
||||||
|
// alongside each event so history replay shows the same turn
|
||||||
|
// start/end times the live stream did.
|
||||||
|
let rows: Vec<(i64, StoredEvent)> = if let Some(bid) = before_id {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, payload_json FROM events
|
"SELECT id, ts, payload_json FROM events
|
||||||
WHERE id < ?1
|
WHERE id < ?1
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?2",
|
LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
stmt.query_map(params![bid, fetch], |row| {
|
stmt.query_map(params![bid, fetch], |row| {
|
||||||
let id: i64 = row.get(0)?;
|
let id: i64 = row.get(0)?;
|
||||||
let s: String = row.get(1)?;
|
let ts: i64 = row.get(1)?;
|
||||||
Ok(serde_json::from_str::<LiveEvent>(&s).ok().map(|e| (id, e)))
|
let s: String = row.get(2)?;
|
||||||
|
Ok(serde_json::from_str::<LiveEvent>(&s)
|
||||||
|
.ok()
|
||||||
|
.map(|event| (id, StoredEvent { ts, event })))
|
||||||
})?
|
})?
|
||||||
.flatten()
|
.flatten()
|
||||||
.flatten()
|
.flatten()
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, payload_json FROM events
|
"SELECT id, ts, payload_json FROM events
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?1",
|
LIMIT ?1",
|
||||||
)?;
|
)?;
|
||||||
stmt.query_map(params![fetch], |row| {
|
stmt.query_map(params![fetch], |row| {
|
||||||
let id: i64 = row.get(0)?;
|
let id: i64 = row.get(0)?;
|
||||||
let s: String = row.get(1)?;
|
let ts: i64 = row.get(1)?;
|
||||||
Ok(serde_json::from_str::<LiveEvent>(&s).ok().map(|e| (id, e)))
|
let s: String = row.get(2)?;
|
||||||
|
Ok(serde_json::from_str::<LiveEvent>(&s)
|
||||||
|
.ok()
|
||||||
|
.map(|event| (id, StoredEvent { ts, event })))
|
||||||
})?
|
})?
|
||||||
.flatten()
|
.flatten()
|
||||||
.flatten()
|
.flatten()
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
let has_more = rows.len() > limit;
|
let has_more = rows.len() > limit;
|
||||||
let mut rows: Vec<(i64, LiveEvent)> = rows.into_iter().take(limit).collect();
|
let mut rows: Vec<(i64, StoredEvent)> = rows.into_iter().take(limit).collect();
|
||||||
rows.reverse(); // oldest first
|
rows.reverse(); // oldest first
|
||||||
let min_id = rows.first().map(|(id, _)| *id);
|
let min_id = rows.first().map(|(id, _)| *id);
|
||||||
let events = rows.into_iter().map(|(_, e)| e).collect();
|
let events = rows.into_iter().map(|(_, e)| e).collect();
|
||||||
|
|
@ -1003,6 +1029,7 @@ impl Bus {
|
||||||
}
|
}
|
||||||
let envelope = BusEvent {
|
let envelope = BusEvent {
|
||||||
seq: self.next_seq(),
|
seq: self.next_seq(),
|
||||||
|
ts: now_unix(),
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
// Lagged subscribers drop events — fine; the UI is a tail, not a log.
|
// Lagged subscribers drop events — fine; the UI is a tail, not a log.
|
||||||
|
|
@ -1018,7 +1045,7 @@ impl Bus {
|
||||||
/// Drives the terminal pre-fill when the operator opens the agent
|
/// Drives the terminal pre-fill when the operator opens the agent
|
||||||
/// page; without a store (db open failed) this is empty.
|
/// page; without a store (db open failed) this is empty.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn history(&self) -> Vec<LiveEvent> {
|
pub fn history(&self) -> Vec<StoredEvent> {
|
||||||
let Some(store) = &self.store else {
|
let Some(store) = &self.store else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
|
@ -1035,7 +1062,7 @@ impl Bus {
|
||||||
&self,
|
&self,
|
||||||
before_id: Option<i64>,
|
before_id: Option<i64>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> (Vec<LiveEvent>, Option<i64>, bool) {
|
) -> (Vec<StoredEvent>, Option<i64>, bool) {
|
||||||
let Some(store) = &self.store else {
|
let Some(store) = &self.store else {
|
||||||
return (Vec::new(), None, false);
|
return (Vec::new(), None, false);
|
||||||
};
|
};
|
||||||
|
|
@ -1051,9 +1078,41 @@ impl Default for Bus {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{DEFAULT_EFFORT, EFFORT_LEVELS, TokenUsage, is_valid_effort};
|
use super::{
|
||||||
|
BusEvent, DEFAULT_EFFORT, EFFORT_LEVELS, LiveEvent, StoredEvent, TokenUsage,
|
||||||
|
is_valid_effort,
|
||||||
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_event_serializes_ts_beside_kind() {
|
||||||
|
// History-row wire shape: `ts` is a flattened sibling of `kind`,
|
||||||
|
// which is what the agent terminal reads to time turn boundaries.
|
||||||
|
let v = serde_json::to_value(StoredEvent {
|
||||||
|
ts: 1_700_000_000,
|
||||||
|
event: LiveEvent::Note { text: "hi".into() },
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v["ts"], 1_700_000_000_i64);
|
||||||
|
assert_eq!(v["kind"], "note");
|
||||||
|
assert_eq!(v["text"], "hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bus_event_serializes_ts_and_seq_beside_kind() {
|
||||||
|
// Live SSE frame: same `ts` sibling as history (plus `seq`), so the
|
||||||
|
// renderer is path-agnostic between live + scrollback.
|
||||||
|
let v = serde_json::to_value(BusEvent {
|
||||||
|
seq: 7,
|
||||||
|
ts: 1_700_000_000,
|
||||||
|
event: LiveEvent::Note { text: "yo".into() },
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v["seq"], 7);
|
||||||
|
assert_eq!(v["ts"], 1_700_000_000_i64);
|
||||||
|
assert_eq!(v["kind"], "note");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn effort_validation_accepts_only_known_levels() {
|
fn effort_validation_accepts_only_known_levels() {
|
||||||
for level in EFFORT_LEVELS {
|
for level in EFFORT_LEVELS {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ pub async fn run(socket: PathBuf) {
|
||||||
let token_path = format!("{state_dir}/forge-token");
|
let token_path = format!("{state_dir}/forge-token");
|
||||||
// Retry reading the token to handle races where hive-priv provisions the
|
// Retry reading the token to handle races where hive-priv provisions the
|
||||||
// token after the harness starts, or where a parent-container chown briefly
|
// token after the harness starts, or where a parent-container chown briefly
|
||||||
// makes the file unreadable (see #1304 / #1309). We wait up to
|
// makes the file unreadable. We wait up to
|
||||||
// TOKEN_RETRY_MAX * TOKEN_RETRY_SECS before giving up.
|
// TOKEN_RETRY_MAX * TOKEN_RETRY_SECS before giving up.
|
||||||
let token = {
|
let token = {
|
||||||
let mut attempts = 0u32;
|
let mut attempts = 0u32;
|
||||||
|
|
@ -205,14 +205,14 @@ fn escape_md_headings(body: &str) -> String {
|
||||||
|
|
||||||
/// Strict `CommonMark` ATX-heading detector: 1-6 leading `#`s followed
|
/// Strict `CommonMark` ATX-heading detector: 1-6 leading `#`s followed
|
||||||
/// by either a space, tab, or end-of-line. Anything tighter (`#tag`,
|
/// by either a space, tab, or end-of-line. Anything tighter (`#tag`,
|
||||||
/// `#123`) is a non-heading line that the renderer will not promote.
|
/// `#9`) is a non-heading line that the renderer will not promote.
|
||||||
fn is_atx_heading(line: &str) -> bool {
|
fn is_atx_heading(line: &str) -> bool {
|
||||||
let hashes = line.bytes().take_while(|&b| b == b'#').count();
|
let hashes = line.bytes().take_while(|&b| b == b'#').count();
|
||||||
if !(1..=6).contains(&hashes) {
|
if !(1..=6).contains(&hashes) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Bare `#` / `##` / ... on its own line, or proper ATX with a
|
// Bare `#` / `##` / ... on its own line, or proper ATX with a
|
||||||
// space/tab after the run of `#`s; anything else (`#tag` / `#123`)
|
// space/tab after the run of `#`s; anything else (`#tag` / `#9`)
|
||||||
// is not a heading.
|
// is not a heading.
|
||||||
matches!(line.as_bytes().get(hashes), None | Some(b' ' | b'\t'))
|
matches!(line.as_bytes().get(hashes), None | Some(b' ' | b'\t'))
|
||||||
}
|
}
|
||||||
|
|
@ -843,11 +843,11 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn escape_md_headings_skips_non_atx_hash_lines() {
|
fn escape_md_headings_skips_non_atx_hash_lines() {
|
||||||
// ATX requires a space after the `#`s. Lines like `#tag`,
|
// ATX requires a space after the `#`s. Lines like `#tag`, a
|
||||||
// `#123`, `#!/bin/bash` are NOT headings — escaping them
|
// hash-then-digits run, or `#!/bin/bash` are NOT headings —
|
||||||
// would just add cosmetic noise where the renderer
|
// escaping them would just add cosmetic noise where the
|
||||||
// wouldn't promote the line in the first place.
|
// renderer wouldn't promote the line in the first place.
|
||||||
let body = "#tag\n#123\n#!/bin/bash\n####### too many hashes\nbody";
|
let body = "#tag\n#123\n#!/bin/bash\n####### too many hashes\nbody"; // lint:allow: hash-digit heading test input, not a tracker tag
|
||||||
let escaped = escape_md_headings(body);
|
let escaped = escape_md_headings(body);
|
||||||
// All four leading `#` lines pass through untouched: too few
|
// All four leading `#` lines pass through untouched: too few
|
||||||
// (still need space), seven `#`s (over the cap), shebang
|
// (still need space), seven `#`s (over the cap), shebang
|
||||||
|
|
|
||||||
|
|
@ -841,7 +841,7 @@ pub(crate) fn handle_send(
|
||||||
let resolved = crate::topology::resolve_recipient(agent, to);
|
let resolved = crate::topology::resolve_recipient(agent, to);
|
||||||
// Validate that the resolved recipient is a known local agent or the
|
// Validate that the resolved recipient is a known local agent or the
|
||||||
// special "operator" recipient. Without this check a typo in `to`
|
// special "operator" recipient. Without this check a typo in `to`
|
||||||
// silently queues a message nobody will ever read (issue #1165).
|
// silently queues a message nobody will ever read.
|
||||||
//
|
//
|
||||||
// Cross-hive messaging (`name@hive` qualified names) is not routed
|
// Cross-hive messaging (`name@hive` qualified names) is not routed
|
||||||
// through the broker — use the Matrix MCP tools for that instead.
|
// through the broker — use the Matrix MCP tools for that instead.
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,7 @@ impl Broker {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unacknowledged messages addressed to `recipient`, newest-first.
|
/// Unacknowledged messages addressed to `recipient`, newest-first.
|
||||||
/// Backs the dashboard's operator inbox (#1469): the operator never
|
/// Backs the dashboard's operator inbox: the operator never
|
||||||
/// `recv`s over an agent socket, so messages to `"operator"` sit in
|
/// `recv`s over an agent socket, so messages to `"operator"` sit in
|
||||||
/// the broker with `acked_at IS NULL` until the operator hits "mark
|
/// the broker with `acked_at IS NULL` until the operator hits "mark
|
||||||
/// all read" (which calls [`Broker::mark_all_read`]). This read
|
/// all read" (which calls [`Broker::mark_all_read`]). This read
|
||||||
|
|
@ -1352,7 +1352,7 @@ mod tests {
|
||||||
assert_eq!(broker.ack_turn("b").unwrap(), 5);
|
assert_eq!(broker.ack_turn("b").unwrap(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The #1462 fix: a transient `ping` fired while no `recv` is parked
|
/// Transient-wake regression guard: a `ping` fired while no `recv` is parked
|
||||||
/// must NOT be lost — it's buffered and drained by the next collect.
|
/// must NOT be lost — it's buffered and drained by the next collect.
|
||||||
#[test]
|
#[test]
|
||||||
fn transient_ping_buffered_when_no_receiver_parked() {
|
fn transient_ping_buffered_when_no_receiver_parked() {
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,14 @@ pub struct Coordinator {
|
||||||
/// watcher consults both this and the active map before declaring
|
/// watcher consults both this and the active map before declaring
|
||||||
/// a stop deliberate.
|
/// a stop deliberate.
|
||||||
recent_transient: Mutex<HashMap<String, (TransientKind, std::time::Instant)>>,
|
recent_transient: Mutex<HashMap<String, (TransientKind, std::time::Instant)>>,
|
||||||
|
/// Timestamps of recent unexpected container crashes, keyed by agent.
|
||||||
|
/// Fed by `crash_watch` each time it classifies a stop as a crash (so
|
||||||
|
/// a crash-looping container — which `Restart=on-failure` flips back
|
||||||
|
/// to running between polls — accumulates one entry per down-transition,
|
||||||
|
/// not just whatever its point-in-time state happens to be). Read by
|
||||||
|
/// the dashboard's `agents_crashing` banner warning via
|
||||||
|
/// `recent_crash_counts`, which prunes entries older than its window.
|
||||||
|
recent_crashes: Mutex<HashMap<String, Vec<std::time::Instant>>>,
|
||||||
/// 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
|
||||||
|
|
@ -430,6 +438,7 @@ impl Coordinator {
|
||||||
agents: Mutex::new(HashMap::new()),
|
agents: Mutex::new(HashMap::new()),
|
||||||
transient: Mutex::new(HashMap::new()),
|
transient: Mutex::new(HashMap::new()),
|
||||||
recent_transient: Mutex::new(HashMap::new()),
|
recent_transient: Mutex::new(HashMap::new()),
|
||||||
|
recent_crashes: Mutex::new(HashMap::new()),
|
||||||
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),
|
||||||
|
|
@ -1075,6 +1084,33 @@ impl Coordinator {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record an unexpected crash for `agent`. Called by the crash
|
||||||
|
/// watcher whenever it classifies a container stop as a crash (not an
|
||||||
|
/// operator action). Append-only here; pruning happens lazily on read
|
||||||
|
/// in `recent_crash_counts`.
|
||||||
|
pub fn record_crash(&self, agent: &str) {
|
||||||
|
self.recent_crashes
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(agent.to_owned())
|
||||||
|
.or_default()
|
||||||
|
.push(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-agent count of crashes within the last `window`. Lazily reaps
|
||||||
|
/// older timestamps and drops agents with none left, so the map stays
|
||||||
|
/// bounded and only lists agents actively crashing. Powers the
|
||||||
|
/// dashboard's `agents_crashing` banner warning.
|
||||||
|
pub fn recent_crash_counts(&self, window: std::time::Duration) -> HashMap<String, usize> {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let mut map = self.recent_crashes.lock().unwrap();
|
||||||
|
map.retain(|_, times| {
|
||||||
|
times.retain(|ts| now.duration_since(*ts) <= window);
|
||||||
|
!times.is_empty()
|
||||||
|
});
|
||||||
|
map.iter().map(|(k, v)| (k.clone(), v.len())).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Set a transient state and return a guard that clears it on drop.
|
/// Set a transient state and return a guard that clears it on drop.
|
||||||
/// Use this from any path where the surrounding future could be
|
/// Use this from any path where the surrounding future could be
|
||||||
/// cancelled or panic between set and clear (HTTP handlers, spawned
|
/// cancelled or panic between set and clear (HTTP handlers, spawned
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ fn emit_crash_transitions(coord: &Coordinator, prev: &HashSet<String>, current:
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tracing::warn!(agent = %stopped, "container crash detected");
|
tracing::warn!(agent = %stopped, "container crash detected");
|
||||||
|
coord.record_crash(stopped);
|
||||||
coord.notify_manager(&hive_sh4re::HelperEvent::ContainerCrash {
|
coord.notify_manager(&hive_sh4re::HelperEvent::ContainerCrash {
|
||||||
agent: stopped.clone(),
|
agent: stopped.clone(),
|
||||||
note: Some("container stopped without an operator action".into()),
|
note: Some("container stopped without an operator action".into()),
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,13 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Window over which container crashes count toward the `agents_crashing`
|
||||||
|
/// banner warning. Wide enough that a crash-looping container (restarted
|
||||||
|
/// by `Restart=on-failure` every few seconds) keeps the warning lit
|
||||||
|
/// between flaps, short enough that a single recovered crash clears within
|
||||||
|
/// minutes.
|
||||||
|
const CRASH_WARNING_WINDOW: std::time::Duration = std::time::Duration::from_mins(10);
|
||||||
|
|
||||||
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||||
let host = headers
|
let host = headers
|
||||||
.get("host")
|
.get("host")
|
||||||
|
|
@ -523,6 +530,18 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
.map(QuestionView::from_question)
|
.map(QuestionView::from_question)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Banner warnings: host probes (disk) + agent-state (pending logins,
|
||||||
|
// crashing agents). Built before the response struct because the
|
||||||
|
// agent-state producer borrows `containers`, which moves in below.
|
||||||
|
let server_warnings = {
|
||||||
|
let mut w = crate::host_stats::server_warnings();
|
||||||
|
w.extend(crate::host_stats::agent_state_warnings(
|
||||||
|
&containers,
|
||||||
|
&state.coord.recent_crash_counts(CRASH_WARNING_WINDOW),
|
||||||
|
));
|
||||||
|
w
|
||||||
|
};
|
||||||
|
|
||||||
axum::Json(StateSnapshot {
|
axum::Json(StateSnapshot {
|
||||||
seq,
|
seq,
|
||||||
hostname,
|
hostname,
|
||||||
|
|
@ -564,7 +583,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|s| !s.is_empty()),
|
.filter(|s| !s.is_empty()),
|
||||||
peer_hives: parse_peer_hives(),
|
peer_hives: parse_peer_hives(),
|
||||||
server_warnings: crate::host_stats::server_warnings(),
|
server_warnings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1221,8 +1240,8 @@ pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox
|
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox.
|
||||||
/// (#1469). Returns messages addressed to `"operator"` that haven't been
|
/// Returns messages addressed to `"operator"` that haven't been
|
||||||
/// acked yet (the operator clears them via the existing
|
/// acked yet (the operator clears them via the existing
|
||||||
/// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped
|
/// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped
|
||||||
/// tokens are validated so the client renders file links like the
|
/// tokens are validated so the client renders file links like the
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,12 @@
|
||||||
//! pressure, a failed unit, …) is a backend-only change — no frontend
|
//! pressure, a failed unit, …) is a backend-only change — no frontend
|
||||||
//! edit. Keep producers cheap; this runs on every `/api/state` assembly.
|
//! edit. Keep producers cheap; this runs on every `/api/state` assembly.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::container_view::ContainerView;
|
||||||
|
|
||||||
/// One server-level warning for the dashboard's top-of-page banner.
|
/// One server-level warning for the dashboard's top-of-page banner.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ServerWarning {
|
pub struct ServerWarning {
|
||||||
|
|
@ -65,6 +69,69 @@ pub fn server_warnings() -> Vec<ServerWarning> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Agent-state warnings derived from the live container snapshot the
|
||||||
|
/// dashboard already holds: agents that need a claude login, and agents
|
||||||
|
/// that are crashing. Kept separate from [`server_warnings`] (host
|
||||||
|
/// probes) because the caller owns the container list + crash counts;
|
||||||
|
/// the dashboard concatenates both into one banner list.
|
||||||
|
///
|
||||||
|
/// `crash_counts` is `Coordinator::recent_crash_counts(window)` — agent →
|
||||||
|
/// number of crashes inside that window — so a crash-looping agent shows
|
||||||
|
/// its repeat count rather than a single point-in-time flap.
|
||||||
|
#[must_use]
|
||||||
|
pub fn agent_state_warnings<S: std::hash::BuildHasher>(
|
||||||
|
containers: &[ContainerView],
|
||||||
|
crash_counts: &HashMap<String, usize, S>,
|
||||||
|
) -> Vec<ServerWarning> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
// `needs_login` is already running-gated in `container_view::build_all`,
|
||||||
|
// so a stopped container never lights this.
|
||||||
|
let mut pending: Vec<&str> = containers
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.needs_login)
|
||||||
|
.map(|c| c.name.as_str())
|
||||||
|
.collect();
|
||||||
|
if !pending.is_empty() {
|
||||||
|
pending.sort_unstable();
|
||||||
|
out.push(ServerWarning {
|
||||||
|
kind: "pending_logins",
|
||||||
|
level: "warn",
|
||||||
|
message: format!(
|
||||||
|
"{n} agent{plural} {verb} claude login: {list} \
|
||||||
|
— run `hivectl login <agent>` to authenticate",
|
||||||
|
n = pending.len(),
|
||||||
|
plural = if pending.len() == 1 { "" } else { "s" },
|
||||||
|
verb = if pending.len() == 1 { "needs" } else { "need" },
|
||||||
|
list = pending.join(", "),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !crash_counts.is_empty() {
|
||||||
|
let mut crashing: Vec<(&String, usize)> =
|
||||||
|
crash_counts.iter().map(|(a, n)| (a, *n)).collect();
|
||||||
|
crashing.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
|
||||||
|
let list = crashing
|
||||||
|
.iter()
|
||||||
|
.map(|(a, n)| format!("{a} (×{n})"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
out.push(ServerWarning {
|
||||||
|
kind: "agents_crashing",
|
||||||
|
level: "crit",
|
||||||
|
message: format!(
|
||||||
|
"{n} agent{plural} crashing: {list} \
|
||||||
|
— check the container journal (`hivectl logs <agent>`)",
|
||||||
|
n = crashing.len(),
|
||||||
|
plural = if crashing.len() == 1 { "" } else { "s" },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Disk usage for the filesystem backing the host nix store — internal to
|
/// Disk usage for the filesystem backing the host nix store — internal to
|
||||||
/// the disk-pressure producer above.
|
/// the disk-pressure producer above.
|
||||||
struct DiskUsage {
|
struct DiskUsage {
|
||||||
|
|
@ -122,3 +189,71 @@ fn disk_usage(path: &str) -> Option<DiskUsage> {
|
||||||
used_pct,
|
used_pct,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn cv(name: &str, needs_login: bool) -> ContainerView {
|
||||||
|
ContainerView {
|
||||||
|
name: name.to_owned(),
|
||||||
|
container: format!("h-{name}"),
|
||||||
|
port: 0,
|
||||||
|
running: true,
|
||||||
|
needs_update: false,
|
||||||
|
needs_login,
|
||||||
|
deployed_sha: None,
|
||||||
|
pending_reminders: 0,
|
||||||
|
parent: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_agent_warnings_when_all_healthy() {
|
||||||
|
let containers = [cv("alice", false), cv("bob", false)];
|
||||||
|
assert!(agent_state_warnings(&containers, &HashMap::new()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_logins_lists_sorted_agents() {
|
||||||
|
let containers = [cv("zoe", true), cv("amy", false), cv("bob", true)];
|
||||||
|
let w = agent_state_warnings(&containers, &HashMap::new());
|
||||||
|
assert_eq!(w.len(), 1);
|
||||||
|
assert_eq!(w[0].kind, "pending_logins");
|
||||||
|
assert_eq!(w[0].level, "warn");
|
||||||
|
// sorted, login-needing only, count reflected
|
||||||
|
assert!(
|
||||||
|
w[0].message
|
||||||
|
.starts_with("2 agents need claude login: bob, zoe")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn singular_grammar_for_one_agent() {
|
||||||
|
let containers = [cv("solo", true)];
|
||||||
|
let w = agent_state_warnings(&containers, &HashMap::new());
|
||||||
|
assert!(w[0].message.starts_with("1 agent needs claude login: solo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crashing_warning_is_crit_and_count_ordered() {
|
||||||
|
let crashes = HashMap::from([("flap".to_owned(), 5), ("blip".to_owned(), 1)]);
|
||||||
|
let w = agent_state_warnings(&[], &crashes);
|
||||||
|
assert_eq!(w.len(), 1);
|
||||||
|
assert_eq!(w[0].kind, "agents_crashing");
|
||||||
|
assert_eq!(w[0].level, "crit");
|
||||||
|
// higher crash count first
|
||||||
|
assert!(w[0].message.contains("flap (×5), blip (×1)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_warnings_coexist() {
|
||||||
|
let containers = [cv("a", true)];
|
||||||
|
let crashes = HashMap::from([("b".to_owned(), 2)]);
|
||||||
|
let kinds: Vec<&str> = agent_state_warnings(&containers, &crashes)
|
||||||
|
.iter()
|
||||||
|
.map(|w| w.kind)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(kinds, ["pending_logins", "agents_crashing"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1145,7 +1145,7 @@ async fn set_nspawn_flags(
|
||||||
// Make /shared writable by every agent. Containers share host uids (no
|
// Make /shared writable by every agent. Containers share host uids (no
|
||||||
// PrivateUsers), but each agent is a distinct unix user, so a root-owned
|
// PrivateUsers), but each agent is a distinct unix user, so a root-owned
|
||||||
// 0755 dir leaves them unable to write — the documented "read/write for
|
// 0755 dir leaves them unable to write — the documented "read/write for
|
||||||
// all agents" contract was broken (#1374). A setgid group would need a
|
// all agents" contract was broken. A setgid group would need a
|
||||||
// pinned GID declared in every container plus all agent users joined to
|
// pinned GID declared in every container plus all agent users joined to
|
||||||
// it (cross-container coordination + a rebuild cascade); instead we use
|
// it (cross-container coordination + a rebuild cascade); instead we use
|
||||||
// the /tmp model — sticky world-writable (1777). The sticky bit lets any
|
// the /tmp model — sticky world-writable (1777). The sticky bit lets any
|
||||||
|
|
|
||||||
|
|
@ -1224,7 +1224,7 @@ mod tests {
|
||||||
kind: QueueKind::Rebuild,
|
kind: QueueKind::Rebuild,
|
||||||
agent: "agent-a".to_owned(),
|
agent: "agent-a".to_owned(),
|
||||||
source: QueueSource::Approval,
|
source: QueueSource::Approval,
|
||||||
reason: "approval #42 apply commit".to_owned(),
|
reason: "approval 42 apply commit".to_owned(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
inputs: Vec::new(),
|
inputs: Vec::new(),
|
||||||
approval_id: Some(42),
|
approval_id: Some(42),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//! Body-input resolution shared by every verb that posts a body.
|
//! Body-input resolution shared by every verb that posts a body.
|
||||||
//! Matches the bash `resolve_body` helper (#382): exactly one source
|
//! Matches the bash `resolve_body` helper: exactly one source
|
||||||
//! between `--body`, `--body-file`, and piped stdin. Passing both
|
//! between `--body`, `--body-file`, and piped stdin. Passing both
|
||||||
//! `--body` and `--body-file` is a clear error.
|
//! `--body` and `--body-file` is a clear error.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ pub struct Client {
|
||||||
pub default_repo: String,
|
pub default_repo: String,
|
||||||
/// Global `--json` flag — verbs that have a human-readable
|
/// Global `--json` flag — verbs that have a human-readable
|
||||||
/// default path branch on `client.json_mode()` to pick the
|
/// default path branch on `client.json_mode()` to pick the
|
||||||
/// JSON output shape instead. Closes #421.
|
/// JSON output shape instead.
|
||||||
json_mode: bool,
|
json_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,7 +131,7 @@ impl Client {
|
||||||
/// params (`?limit=N&state=open&...`) are preserved. Pages drain
|
/// params (`?limit=N&state=open&...`) are preserved. Pages drain
|
||||||
/// while the response carries a `Link: rel="next"` header, up to
|
/// while the response carries a `Link: rel="next"` header, up to
|
||||||
/// `max_pages` (the runaway-loop safety cap). Returns the merged
|
/// `max_pages` (the runaway-loop safety cap). Returns the merged
|
||||||
/// array. Used by `lint` for repo-wide queries (closes #505).
|
/// array. Used by `lint` for repo-wide queries.
|
||||||
pub fn get_json_all(&self, path: &str, max_pages: u32) -> Result<Vec<Value>> {
|
pub fn get_json_all(&self, path: &str, max_pages: u32) -> Result<Vec<Value>> {
|
||||||
let sep = if path.contains('?') { '&' } else { '?' };
|
let sep = if path.contains('?') { '&' } else { '?' };
|
||||||
let mut merged = Vec::new();
|
let mut merged = Vec::new();
|
||||||
|
|
@ -271,8 +271,8 @@ fn read_token() -> Result<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Surface non-2xx HTTP responses as anyhow errors with the response
|
/// Surface non-2xx HTTP responses as anyhow errors with the response
|
||||||
/// body included (matches `curl --fail-with-body`). Closes #353's
|
/// body included (matches `curl --fail-with-body`) — turns
|
||||||
/// "silent failures with no clue what went wrong" case.
|
/// silent failures into errors with a clear message.
|
||||||
fn check_status(resp: Response, op: &str) -> Result<Response> {
|
fn check_status(resp: Response, op: &str) -> Result<Response> {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
//! Single binary with verb subcommands. Replaces the prior bash
|
//! Single binary with verb subcommands. Replaces the prior bash
|
||||||
//! script (`hive-forge-tools.nix`) so that agents and operators get
|
//! script (`hive-forge-tools.nix`) so that agents and operators get
|
||||||
//! the same error handling, exit codes, and JSON shapes regardless
|
//! the same error handling, exit codes, and JSON shapes regardless
|
||||||
//! of how the bash mood was that day (closes #280).
|
//! of how the bash mood was that day.
|
||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
// Clap-derived `Args` structs are intentionally consumed by their
|
// Clap-derived `Args` structs are intentionally consumed by their
|
||||||
|
|
@ -26,7 +26,7 @@ use clap::{Parser, Subcommand};
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "hive-forge",
|
name = "hive-forge",
|
||||||
about = "Forgejo CLI wrapper for hyperhive (closes #280)",
|
about = "Forgejo CLI wrapper for hyperhive",
|
||||||
disable_help_subcommand = true
|
disable_help_subcommand = true
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
|
|
@ -36,7 +36,7 @@ struct Cli {
|
||||||
#[arg(short = 'r', long, global = true)]
|
#[arg(short = 'r', long, global = true)]
|
||||||
repo: Option<String>,
|
repo: Option<String>,
|
||||||
/// Emit JSON output instead of the verb's default human-readable
|
/// Emit JSON output instead of the verb's default human-readable
|
||||||
/// shape, for verbs that support both (closes #421). Verbs whose
|
/// shape, for verbs that support both. Verbs whose
|
||||||
/// only output is already JSON (`issue`, `pr`, etc.) ignore this
|
/// only output is already JSON (`issue`, `pr`, etc.) ignore this
|
||||||
/// flag — they always print JSON regardless.
|
/// flag — they always print JSON regardless.
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
|
|
@ -100,7 +100,7 @@ enum Verb {
|
||||||
Subscription(verbs::subscription::Args),
|
Subscription(verbs::subscription::Args),
|
||||||
/// List timeline events on an issue or PR (closes, label adds,
|
/// List timeline events on an issue or PR (closes, label adds,
|
||||||
/// assignments, commit refs, pushes, etc.) — the audit trail
|
/// assignments, commit refs, pushes, etc.) — the audit trail
|
||||||
/// `view` + `comments` don't surface (closes #783).
|
/// `view` + `comments` don't surface.
|
||||||
Timeline(verbs::timeline::Args),
|
Timeline(verbs::timeline::Args),
|
||||||
/// Upload a file as an attachment to an issue.
|
/// Upload a file as an attachment to an issue.
|
||||||
AttachIssue(verbs::attach::IssueArgs),
|
AttachIssue(verbs::attach::IssueArgs),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! `assign <number> <user> [--remove]` — add or remove a user from an
|
//! `assign <number> <user> [--remove]` — add or remove a user from an
|
||||||
//! issue/PR's assignee list. Forgejo has no dedicated POST endpoint —
|
//! issue/PR's assignee list. Forgejo has no dedicated POST endpoint —
|
||||||
//! we read the current list, mutate, and PATCH the issue back (closes
|
//! we read the current list, mutate, and PATCH the issue back (Forgejo
|
||||||
//! #353's "no such endpoint" trap; matches the bash helper's logic).
|
//! has no such endpoint; matches the bash helper's logic).
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! `comments <number> [--limit N | --tail N]` — list comments on an
|
//! `comments <number> [--limit N | --tail N]` — list comments on an
|
||||||
//! issue or PR. Closes the curl-fallback gap (#418); `--tail`
|
//! issue or PR. Replaces the curl fallback; `--tail`
|
||||||
//! closes the third of the four #694 gaps (paging-for-long-threads
|
//! handles the paging-for-long-threads
|
||||||
//! awkwardness).
|
//! awkwardness).
|
||||||
//!
|
//!
|
||||||
//! - `--limit N` (default 50, Forgejo's cap) returns the first N
|
//! - `--limit N` (default 50, Forgejo's cap) returns the first N
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
//! on this long thread?" without scrolling through the whole
|
//! on this long thread?" without scrolling through the whole
|
||||||
//! history.
|
//! history.
|
||||||
//!
|
//!
|
||||||
//! Use the global `--json` flag for JSON output (#421).
|
//! Use the global `--json` flag for JSON output.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
//! `package-lock.json`, …) is collapsed to a single
|
//! `package-lock.json`, …) is collapsed to a single
|
||||||
//! `[<path>: contents changed (+N -M, --full for content)]`
|
//! `[<path>: contents changed (+N -M, --full for content)]`
|
||||||
//! line so a `flake.lock` rev bump doesn't drown the human-
|
//! line so a `flake.lock` rev bump doesn't drown the human-
|
||||||
//! authored changes in 5 000 lines of lock churn (#222). The
|
//! authored changes in 5 000 lines of lock churn. The
|
||||||
//! per-file git headers (`diff --git`, `index`, `---`, `+++`,
|
//! per-file git headers (`diff --git`, `index`, `---`, `+++`,
|
||||||
//! and any rename / mode metadata) are suppressed alongside the
|
//! and any rename / mode metadata) are suppressed alongside the
|
||||||
//! hunks since the placeholder already carries the file path and
|
//! hunks since the placeholder already carries the file path and
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//! `lint <subcommand>` — issue/PR/branch lint queries for triage
|
//! `lint <subcommand>` — issue/PR/branch lint queries for triage
|
||||||
//! workflows (closes #505). Replaces ad-hoc curl + jq filtering with
|
//! workflows. Replaces ad-hoc curl + jq filtering with
|
||||||
//! typed commands that always emit JSON via the global `--json`
|
//! typed commands that always emit JSON via the global `--json`
|
||||||
//! (default is a compact one-line-per-item human shape).
|
//! (default is a compact one-line-per-item human shape).
|
||||||
//!
|
//!
|
||||||
|
|
@ -97,7 +97,7 @@ struct NoReviewerArgs {
|
||||||
/// Reviewer login to look for (matches `@<reviewer>` in PR body or
|
/// Reviewer login to look for (matches `@<reviewer>` in PR body or
|
||||||
/// any comment). Required — defaulting to a specific name would
|
/// any comment). Required — defaulting to a specific name would
|
||||||
/// bake one deployment's reviewer convention into the binary
|
/// bake one deployment's reviewer convention into the binary
|
||||||
/// (mara's nit on #507).
|
/// (flagged in review).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
reviewer: String,
|
reviewer: String,
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +177,7 @@ fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Paginate so PRs with >50 comments don't yield false positives
|
// Paginate so PRs with >50 comments don't yield false positives
|
||||||
// (argus nit on #507). Same 1000-comment ceiling as elsewhere.
|
// (flagged in review). Same 1000-comment ceiling as elsewhere.
|
||||||
let comments = client.get_json_all(
|
let comments = client.get_json_all(
|
||||||
&format!("/repos/{repo}/issues/{number}/comments?limit={PAGE_LIMIT}"),
|
&format!("/repos/{repo}/issues/{number}/comments?limit={PAGE_LIMIT}"),
|
||||||
MAX_PAGES,
|
MAX_PAGES,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
//!
|
//!
|
||||||
//! Mirrors Forgejo's `GET /repos/{owner}/{repo}/issues` query-string
|
//! Mirrors Forgejo's `GET /repos/{owner}/{repo}/issues` query-string
|
||||||
//! filters one-for-one so the mental model carries over. Closes the
|
//! filters one-for-one so the mental model carries over. Closes the
|
||||||
//! second of the four #694 gaps (read-side; no boundary concerns —
|
//! read-side curl-fallback gap (no boundary concerns —
|
||||||
//! every agent + the operator queries the issue tracker constantly).
|
//! every agent + the operator queries the issue tracker constantly).
|
||||||
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
//! hint block is filtered out of git's stderr (we print the canonical
|
//! hint block is filtered out of git's stderr (we print the canonical
|
||||||
//! URL ourselves once the API returns). Other git stderr passes
|
//! URL ourselves once the API returns). Other git stderr passes
|
||||||
//! through. Default behaviour is unchanged: no push unless asked.
|
//! through. Default behaviour is unchanged: no push unless asked.
|
||||||
//! Closes the auto-push half of #222 per operator decision (opt-in
|
//! Adds the auto-push path per operator decision (opt-in
|
||||||
//! flag).
|
//! flag).
|
||||||
//!
|
//!
|
||||||
//! With `--agit` the PR is opened via Forgejo's `AGit` flow instead of
|
//! With `--agit` the PR is opened via Forgejo's `AGit` flow instead of
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! `timeline <number> [--limit N]` — list timeline events on an
|
//! `timeline <number> [--limit N]` — list timeline events on an
|
||||||
//! issue or PR. Closes #783 (last piece of the #694 epic: agents kept
|
//! issue or PR. Fills the gap where agents kept
|
||||||
//! falling back to curl for "who closed this?" / "when was this
|
//! falling back to curl for "who closed this?" / "when was this
|
||||||
//! labelled?" archaeology). Composes naturally with `view <n>` /
|
//! labelled?" archaeology. Composes naturally with `view <n>` /
|
||||||
//! `comments <n>` — separate verb keeps the existing shapes stable.
|
//! `comments <n>` — separate verb keeps the existing shapes stable.
|
||||||
//!
|
//!
|
||||||
//! Forgejo's `/issues/{n}/timeline` endpoint returns BOTH the actual
|
//! Forgejo's `/issues/{n}/timeline` endpoint returns BOTH the actual
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
//!
|
//!
|
||||||
//! `--tail N` is a follow-up (the timeline endpoint doesn't expose a
|
//! `--tail N` is a follow-up (the timeline endpoint doesn't expose a
|
||||||
//! total-count field so we can't use the count-then-page trick that
|
//! total-count field so we can't use the count-then-page trick that
|
||||||
//! `comments --tail` lands in #770; future shape probably mirrors
|
//! `comments --tail` uses; future shape probably mirrors
|
||||||
//! `comments --tail` once Forgejo grows a `count` query or we accept
|
//! `comments --tail` once Forgejo grows a `count` query or we accept
|
||||||
//! the trailing-slice cost).
|
//! the trailing-slice cost).
|
||||||
|
|
||||||
|
|
@ -211,7 +211,7 @@ mod tests {
|
||||||
//! Tests call `format_event` directly so any new event-type arm
|
//! Tests call `format_event` directly so any new event-type arm
|
||||||
//! added in `print_event`'s dispatch is automatically covered by
|
//! added in `print_event`'s dispatch is automatically covered by
|
||||||
//! the rendering path (no parallel test-side dispatch to keep in
|
//! the rendering path (no parallel test-side dispatch to keep in
|
||||||
//! sync). Argus on PR #798 🟡: "extract a `format_event(ev) ->
|
//! sync). A review flagged: "extract a `format_event(ev) ->
|
||||||
//! String` helper and test that function directly instead of
|
//! String` helper and test that function directly instead of
|
||||||
//! duplicating the logic" — addressed.
|
//! duplicating the logic" — addressed.
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
//! the payload to claude; `Error { message }` becomes the tool-call
|
//! the payload to claude; `Error { message }` becomes the tool-call
|
||||||
//! error message claude sees).
|
//! error message claude sees).
|
||||||
//!
|
//!
|
||||||
//! Tool surface mirrors damocles-daemon's v0 set per mara on #548:
|
//! Tool surface mirrors damocles-daemon's v0 set per the operator's call:
|
||||||
//! `send_message`, `send_dm`, `send_reaction`, `send_reply`, `mark_read`,
|
//! `send_message`, `send_dm`, `send_reaction`, `send_reply`, `mark_read`,
|
||||||
//! `list_rooms`, `list_room_members`, `read_room`. Plus a `ping` for the
|
//! `list_rooms`, `list_room_members`, `read_room`. Plus a `ping` for the
|
||||||
//! MCP bridge's liveness probe.
|
//! MCP bridge's liveness probe.
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
//! The wire protocol between the two binaries lives in [`protocol`].
|
//! The wire protocol between the two binaries lives in [`protocol`].
|
||||||
//! Path helpers (token file, daemon socket) live in [`paths`].
|
//! Path helpers (token file, daemon socket) live in [`paths`].
|
||||||
//!
|
//!
|
||||||
//! Phase 3 of #548. Architecture rationale + tool surface mirror the
|
//! Architecture rationale and tool surface mirror the existing
|
||||||
//! existing `damocles-daemon` (see issue thread for details).
|
//! `damocles-daemon` v0 set.
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ pub const DEFAULT_HOMESERVER: &str = "http://localhost:8008";
|
||||||
/// Lives under systemd's `RuntimeDirectory=hive-matrix` (a tmpfs path
|
/// Lives under systemd's `RuntimeDirectory=hive-matrix` (a tmpfs path
|
||||||
/// that disappears on container restart — fine, because the daemon
|
/// that disappears on container restart — fine, because the daemon
|
||||||
/// recreates the socket on its own boot) so the agent unix user
|
/// recreates the socket on its own boot) so the agent unix user
|
||||||
/// (post-#658) can bind a socket inside it without root in `/run`.
|
/// can bind a socket inside it without root in `/run`.
|
||||||
pub const DEFAULT_DAEMON_SOCKET: &str = "/run/hive-matrix/socket";
|
pub const DEFAULT_DAEMON_SOCKET: &str = "/run/hive-matrix/socket";
|
||||||
|
|
||||||
/// Resolve the matrix access-token file path. Override via
|
/// Resolve the matrix access-token file path. Override via
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Matrix event handlers: incoming room messages fire a hyperhive
|
//! Matrix event handlers: incoming room messages fire a hyperhive
|
||||||
//! wake signal so the agent's harness drives a new claude turn.
|
//! wake signal so the agent's harness drives a new claude turn.
|
||||||
//!
|
//!
|
||||||
//! Per mara on #548: wake body is a SHORT TEASER, not the full message
|
//! Per the operator's call: wake body is a SHORT TEASER, not the full message
|
||||||
//! (msg stays unread server-side; agent fetches via `read_room`). The
|
//! (msg stays unread server-side; agent fetches via `read_room`). The
|
||||||
//! `wake::format_wake_body` truncates to ~100 chars.
|
//! `wake::format_wake_body` truncates to ~100 chars.
|
||||||
//!
|
//!
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
//! The agent harness's `agent_server` parses it and treats it as a
|
//! The agent harness's `agent_server` parses it and treats it as a
|
||||||
//! `Wake` from the matrix subsystem.
|
//! `Wake` from the matrix subsystem.
|
||||||
//!
|
//!
|
||||||
//! Per mara's call on #548 phase 3: the body is a SHORT TEASER, not
|
//! Per the operator's call (phase 3): the body is a SHORT TEASER, not
|
||||||
//! the full message — the agent then reads the unmarked event via
|
//! the full message — the agent then reads the unmarked event via
|
||||||
//! the `read_room` MCP tool. Truncation to ~100 chars keeps the wake
|
//! the `read_room` MCP tool. Truncation to ~100 chars keeps the wake
|
||||||
//! prompt focused (`forge_notify` embeds longer excerpts because the
|
//! prompt focused (`forge_notify` embeds longer excerpts because the
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ pub fn core_avatar_png() -> PathBuf {
|
||||||
/// `$HIVE_ASSETS_DIR/branding/agent-configs.png` — secondary org
|
/// `$HIVE_ASSETS_DIR/branding/agent-configs.png` — secondary org
|
||||||
/// mark for the `agent-configs/` mirror org. Rendered from
|
/// mark for the `agent-configs/` mirror org. Rendered from
|
||||||
/// `agent-configs.svg` at asset-build time (was rendered in
|
/// `agent-configs.svg` at asset-build time (was rendered in
|
||||||
/// `hive-c0re/build.rs` before #555).
|
/// `hive-c0re/build.rs`).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn config_org_avatar_png() -> PathBuf {
|
pub fn config_org_avatar_png() -> PathBuf {
|
||||||
dir().join("branding/agent-configs.png")
|
dir().join("branding/agent-configs.png")
|
||||||
|
|
|
||||||
|
|
@ -19,16 +19,24 @@
|
||||||
# overruns) and digit-runs followed by a letter — e.g. hash-route
|
# overruns) and digit-runs followed by a letter — e.g. hash-route
|
||||||
# fragments like #24h. Residual: a pure-numeric short hex (e.g. three
|
# fragments like #24h. Residual: a pure-numeric short hex (e.g. three
|
||||||
# identical digits) trips it — write the six-digit form to dodge.
|
# identical digits) trips it — write the six-digit form to dodge.
|
||||||
|
#
|
||||||
|
# Escape hatch: a line containing the marker `lint:allow` is exempt.
|
||||||
|
# Reserve it for genuine `#<digits>` that aren't tracker tags — e.g. a
|
||||||
|
# `#123` markdown-heading example or hash-prefixed test-input data —
|
||||||
|
# and keep a short reason next to the marker. Don't use it to keep a
|
||||||
|
# real tracker tag; rewrite those to prose.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
pattern='#[0-9]{2,5}([^0-9a-zA-Z]|$)'
|
pattern='#[0-9]{2,5}([^0-9a-zA-Z]|$)'
|
||||||
|
|
||||||
# `/dev/null` forces grep to always print a filename prefix, even when
|
# `/dev/null` forces grep to always print a filename prefix, even when
|
||||||
# xargs hands it a single file. `-r`/`-0` keep it robust to odd paths
|
# xargs hands it a single file. `-r`/`-0` keep it robust to odd paths
|
||||||
# and an empty file list.
|
# and an empty file list. Lines carrying the `lint:allow` marker are
|
||||||
|
# dropped (legitimate non-tracker `#<digits>`; see the header).
|
||||||
hits="$(
|
hits="$(
|
||||||
git ls-files -z '*.rs' '*.nix' '*.js' '*.ts' '*.css' '*.html' \
|
git ls-files -z '*.rs' '*.nix' '*.js' '*.ts' '*.css' '*.html' \
|
||||||
| xargs -0 -r grep -nE "$pattern" /dev/null 2>/dev/null || true
|
| xargs -0 -r grep -nE "$pattern" /dev/null 2>/dev/null \
|
||||||
|
| grep -v 'lint:allow' || true
|
||||||
)"
|
)"
|
||||||
|
|
||||||
if [ -n "$hits" ]; then
|
if [ -n "$hits" ]; then
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue