feat(gateway): hivectl gateway user management + fix htpasswdFile assertion
Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.
Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.
Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
This commit is contained in:
parent
25d2951d1e
commit
4bff450343
61 changed files with 1084 additions and 547 deletions
|
|
@ -60,10 +60,7 @@ fn tasks_dir() -> PathBuf {
|
|||
} else {
|
||||
// Pre-split fallback: derive harness/ as a sibling of state/.
|
||||
let state = crate::paths::state_dir();
|
||||
state
|
||||
.parent()
|
||||
.map(|p| p.join("harness"))
|
||||
.unwrap_or(state)
|
||||
state.parent().map(|p| p.join("harness")).unwrap_or(state)
|
||||
};
|
||||
base.join("bash-tasks")
|
||||
}
|
||||
|
|
@ -136,9 +133,8 @@ impl TaskFile {
|
|||
|
||||
/// Write a task file atomically (tmp + rename).
|
||||
fn write_task(task: &TaskFile) -> std::io::Result<()> {
|
||||
let json = serde_json::to_string_pretty(task).map_err(|e| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
|
||||
})?;
|
||||
let json = serde_json::to_string_pretty(task)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
let dest = task_json(&task.id);
|
||||
let tmp = dest.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
|
|
@ -229,7 +225,7 @@ async fn run_loop(socket: PathBuf) {
|
|||
let claimed: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||
|
||||
loop {
|
||||
poll_once(&socket, &claimed);
|
||||
poll_once(&socket, &claimed).await;
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
|
@ -237,14 +233,20 @@ async fn run_loop(socket: PathBuf) {
|
|||
/// On boot, find any task files in `running` state and flip them to
|
||||
/// `interrupted`, then fire a wake so the agent unblocks.
|
||||
async fn mark_interrupted(socket: &Path) {
|
||||
let Ok(rd) = std::fs::read_dir(tasks_dir()) else { return };
|
||||
let Ok(rd) = std::fs::read_dir(tasks_dir()) else {
|
||||
return;
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
|
||||
let Some(mut task) = read_task(&id) else { continue };
|
||||
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
let Some(mut task) = read_task(&id) else {
|
||||
continue;
|
||||
};
|
||||
if task.status != TaskStatus::Running {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -258,14 +260,18 @@ async fn mark_interrupted(socket: &Path) {
|
|||
}
|
||||
}
|
||||
|
||||
fn poll_once(socket: &Path, claimed: &Arc<Mutex<HashSet<String>>>) {
|
||||
let Ok(rd) = std::fs::read_dir(tasks_dir()) else { return };
|
||||
async fn poll_once(socket: &Path, claimed: &Arc<Mutex<HashSet<String>>>) {
|
||||
let Ok(rd) = std::fs::read_dir(tasks_dir()) else {
|
||||
return;
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
|
||||
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
{
|
||||
let guard = claimed.lock().unwrap();
|
||||
if guard.contains(&id) {
|
||||
|
|
@ -322,7 +328,11 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
|
|||
let stdout_tail = tail_file(&out_path, SUMMARY_BYTES);
|
||||
let stderr_tail = tail_file(&err_path, SUMMARY_BYTES);
|
||||
|
||||
task.status = if timed_out { TaskStatus::TimedOut } else { TaskStatus::Done };
|
||||
task.status = if timed_out {
|
||||
TaskStatus::TimedOut
|
||||
} else {
|
||||
TaskStatus::Done
|
||||
};
|
||||
task.completed_at = Some(crate::serve_common::now_unix());
|
||||
task.exit_code = exit_code;
|
||||
task.stdout_tail = stdout_tail.clone().filter(|s| !s.is_empty());
|
||||
|
|
@ -344,7 +354,12 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
|
|||
|
||||
/// Run `sh -c cmd`, streaming output to files. Returns `(exit_code, timed_out)`.
|
||||
/// On timeout the child process is explicitly killed before returning.
|
||||
async fn exec_cmd(cmd: &str, out_path: &Path, err_path: &Path, timeout: Duration) -> Result<(i32, bool)> {
|
||||
async fn exec_cmd(
|
||||
cmd: &str,
|
||||
out_path: &Path,
|
||||
err_path: &Path,
|
||||
timeout: Duration,
|
||||
) -> Result<(i32, bool)> {
|
||||
use tokio::process::Command;
|
||||
let mut child = Command::new("sh")
|
||||
.arg("-c")
|
||||
|
|
@ -396,7 +411,9 @@ where
|
|||
let _ = tokio::io::copy(&mut reader, &mut f).await;
|
||||
let _ = f.flush().await;
|
||||
}
|
||||
Err(e) => tracing::warn!(path = %path.display(), error = ?e, "bash_runner: open output file failed"),
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = ?e, "bash_runner: open output file failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -415,12 +432,7 @@ fn tail_file(path: &Path, max_bytes: usize) -> Option<String> {
|
|||
// Wake delivery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn send_wake(
|
||||
socket: &Path,
|
||||
id: &str,
|
||||
summary: &str,
|
||||
output: Option<(&str, &str)>,
|
||||
) {
|
||||
async fn send_wake(socket: &Path, id: &str, summary: &str, output: Option<(&str, &str)>) {
|
||||
let mut body = format!("bash task `{id}` finished: {summary}");
|
||||
if let Some((stdout, stderr)) = output {
|
||||
if !stdout.is_empty() {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ use clap::{Parser, Subcommand};
|
|||
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||
use hive_ag3nt::login::{self, LoginState};
|
||||
use hive_ag3nt::turn_stats::TurnStats;
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui};
|
||||
use hive_ag3nt::{
|
||||
DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui,
|
||||
};
|
||||
use hive_sh4re::{
|
||||
AgentRequest, AgentResponse, HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER,
|
||||
};
|
||||
|
|
@ -129,7 +131,9 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) {
|
|||
} else {
|
||||
tracing::info!(%from, %body, "system message");
|
||||
}
|
||||
bus.emit(LiveEvent::Note { text: format!("[system] {body}") });
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!("[system] {body}"),
|
||||
});
|
||||
}
|
||||
|
||||
/// Body string for the turn-failure notification we route to
|
||||
|
|
@ -140,7 +144,11 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) {
|
|||
/// misconfigured harness still produces a parseable line.
|
||||
fn format_turn_failure(err: &anyhow::Error) -> String {
|
||||
let who = hive_ag3nt::identity::qualified_label();
|
||||
let who = if who.is_empty() { "<unknown>".to_owned() } else { who };
|
||||
let who = if who.is_empty() {
|
||||
"<unknown>".to_owned()
|
||||
} else {
|
||||
who
|
||||
};
|
||||
format!("[system] `{who}` claude turn failed:\n{err:#}")
|
||||
}
|
||||
|
||||
|
|
@ -202,9 +210,7 @@ trait Surface {
|
|||
|
||||
/// `(open_threads, open_reminders)` for the post-turn stats row.
|
||||
/// Either field is `None` when the underlying request errors.
|
||||
fn post_turn_counts(
|
||||
socket: &Path,
|
||||
) -> impl Future<Output = (Option<u64>, Option<u64>)>;
|
||||
fn post_turn_counts(socket: &Path) -> impl Future<Output = (Option<u64>, Option<u64>)>;
|
||||
|
||||
/// Send a message addressed to `<parent>` (broker resolves the
|
||||
/// sentinel via `topology::parent_of` at delivery time; root
|
||||
|
|
@ -225,11 +231,8 @@ trait Surface {
|
|||
/// by co-process daemons like matrix to push events into the
|
||||
/// harness inbox). Errors out via `anyhow::bail!` so the calling
|
||||
/// binary surfaces them on stderr.
|
||||
fn wake_external(
|
||||
socket: &Path,
|
||||
from: String,
|
||||
body: String,
|
||||
) -> impl Future<Output = Result<()>>;
|
||||
fn wake_external(socket: &Path, from: String, body: String)
|
||||
-> impl Future<Output = Result<()>>;
|
||||
}
|
||||
|
||||
// ---------- AgentSurface ----------
|
||||
|
|
@ -271,13 +274,15 @@ impl Surface for AgentSurface {
|
|||
}
|
||||
|
||||
async fn post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
|
||||
let threads =
|
||||
match client::request::<_, AgentResponse>(socket, &AgentRequest::GetLooseEnds { agent: None }).await {
|
||||
Ok(AgentResponse::LooseEnds { loose_ends }) => {
|
||||
u64::try_from(loose_ends.len()).ok()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let threads = match client::request::<_, AgentResponse>(
|
||||
socket,
|
||||
&AgentRequest::GetLooseEnds { agent: None },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(AgentResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
|
||||
_ => None,
|
||||
};
|
||||
let reminders = match client::request::<_, AgentResponse>(
|
||||
socket,
|
||||
&AgentRequest::CountPendingReminders { agent: None },
|
||||
|
|
@ -415,9 +420,7 @@ impl Surface for ManagerSurface {
|
|||
)
|
||||
.await
|
||||
{
|
||||
Ok(ManagerResponse::LooseEnds { loose_ends }) => {
|
||||
u64::try_from(loose_ends.len()).ok()
|
||||
}
|
||||
Ok(ManagerResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
|
||||
_ => None,
|
||||
};
|
||||
let reminders = match client::request::<_, ManagerResponse>(
|
||||
|
|
@ -569,8 +572,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
|||
);
|
||||
tokio::spawn(async move {
|
||||
let (label, port, login_state, bus, socket, files, turn_lock) = web_ui_args;
|
||||
if let Err(e) =
|
||||
web_ui::serve(label, port, login_state, bus, socket, files, turn_lock).await
|
||||
if let Err(e) = web_ui::serve(label, port, login_state, bus, socket, files, turn_lock).await
|
||||
{
|
||||
tracing::error!(error = %e, "web_ui::serve exited with error");
|
||||
}
|
||||
|
|
@ -658,7 +660,11 @@ async fn handle_turn<S: Surface>(
|
|||
log_system_event(bus, &from, &body);
|
||||
tracing::info!(%from, %body, %redelivered, "inbox");
|
||||
let unread = S::inbox_unread(socket).await;
|
||||
bus.emit(LiveEvent::TurnStart { from: from.clone(), body: body.clone(), unread });
|
||||
bus.emit(LiveEvent::TurnStart {
|
||||
from: from.clone(),
|
||||
body: body.clone(),
|
||||
unread,
|
||||
});
|
||||
bus.set_state(TurnState::Thinking);
|
||||
let started_at = serve_common::now_unix();
|
||||
let started_instant = std::time::Instant::now();
|
||||
|
|
@ -670,7 +676,10 @@ async fn handle_turn<S: Surface>(
|
|||
};
|
||||
turn::emit_turn_end(bus, &outcome);
|
||||
bus.set_state(TurnState::Idle);
|
||||
if matches!(outcome, turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted) {
|
||||
if matches!(
|
||||
outcome,
|
||||
turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted
|
||||
) {
|
||||
S::ack_turn(socket).await;
|
||||
}
|
||||
if matches!(outcome, turn::TurnOutcome::RateLimited) {
|
||||
|
|
@ -697,8 +706,7 @@ async fn handle_turn<S: Surface>(
|
|||
}
|
||||
if let Some(stats) = stats {
|
||||
let ended_at = serve_common::now_unix();
|
||||
let duration_ms =
|
||||
i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
|
||||
let duration_ms = i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
|
||||
let (open_threads, open_reminders) = S::post_turn_counts(socket).await;
|
||||
let row = serve_common::build_row(
|
||||
started_at,
|
||||
|
|
|
|||
|
|
@ -80,12 +80,18 @@ fn harness_json_path() -> PathBuf {
|
|||
|
||||
fn read_harness_state() -> (bool, bool) {
|
||||
// Try the new consolidated file first.
|
||||
if let Ok(raw) = std::fs::read_to_string(harness_json_path())
|
||||
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
|
||||
{
|
||||
let rate_limited = v.get("rate_limited").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
||||
let needs_login = v.get("needs_login").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
||||
return (rate_limited, needs_login);
|
||||
if let Ok(raw) = std::fs::read_to_string(harness_json_path()) {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
let rate_limited = v
|
||||
.get("rate_limited")
|
||||
.and_then(|x| x.as_bool())
|
||||
.unwrap_or(false);
|
||||
let needs_login = v
|
||||
.get("needs_login")
|
||||
.and_then(|x| x.as_bool())
|
||||
.unwrap_or(false);
|
||||
return (rate_limited, needs_login);
|
||||
}
|
||||
}
|
||||
// Fall back to legacy sentinel files written by older harness builds.
|
||||
let state_dir = crate::paths::state_dir();
|
||||
|
|
|
|||
|
|
@ -244,10 +244,7 @@ fn is_username_byte(b: u8) -> bool {
|
|||
/// window so addressed agents never silently miss a mention on a long
|
||||
/// body. See `docs/forge.md::Body excerpt + truncation + heading
|
||||
/// escape` for the truncate-before-escape ordering rule.
|
||||
fn extract_truncated_mention_lines<'a>(
|
||||
full_body: &'a str,
|
||||
included_excerpt: &str,
|
||||
) -> Vec<&'a str> {
|
||||
fn extract_truncated_mention_lines<'a>(full_body: &'a str, included_excerpt: &str) -> Vec<&'a str> {
|
||||
full_body
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
|
|
@ -914,7 +911,10 @@ mod tests {
|
|||
let full = "# @argus check this\nmore body\n";
|
||||
let raw_excerpt = full; // fits entirely
|
||||
let lines = extract_truncated_mention_lines(full, raw_excerpt);
|
||||
assert!(lines.is_empty(), "heading+mention inside window must not be re-surfaced, got {lines:?}");
|
||||
assert!(
|
||||
lines.is_empty(),
|
||||
"heading+mention inside window must not be re-surfaced, got {lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -122,7 +122,9 @@ mod tests {
|
|||
swarm_name: Option<&str>,
|
||||
f: F,
|
||||
) {
|
||||
let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let prev_label = env::var("HIVE_LABEL").ok();
|
||||
let prev_domain = env::var("HYPERHIVE_HIVE_DOMAIN").ok();
|
||||
let prev_hive_name = env::var("HYPERHIVE_HIVE_NAME").ok();
|
||||
|
|
|
|||
|
|
@ -437,31 +437,30 @@ fn format_bash_status(id: &str) -> String {
|
|||
let Some(task) = crate::bash_runner::read_task(id) else {
|
||||
return format!("bash_status: unknown task id `{id}`");
|
||||
};
|
||||
let mut out = format!(
|
||||
"task `{id}`: status={status:?}",
|
||||
status = task.status
|
||||
);
|
||||
let mut out = format!("task `{id}`: status={status:?}", status = task.status);
|
||||
if let Some(code) = task.exit_code {
|
||||
let _ = write!(out, ", exit={code}");
|
||||
}
|
||||
if let Some(t) = task.started_at && task.completed_at.is_none() {
|
||||
if let Some(t) = task.started_at
|
||||
&& task.completed_at.is_none()
|
||||
{
|
||||
let age = crate::serve_common::now_unix() - t;
|
||||
let _ = write!(out, ", running for {age}s");
|
||||
}
|
||||
if let Some(t) = task.completed_at
|
||||
&& let Some(s) = task.started_at
|
||||
{
|
||||
let _ = write!(out, ", took {}s", t - s);
|
||||
if let Some(t) = task.completed_at {
|
||||
if let Some(s) = task.started_at {
|
||||
let _ = write!(out, ", took {}s", t - s);
|
||||
}
|
||||
}
|
||||
if let Some(ref stdout) = task.stdout_tail
|
||||
&& !stdout.trim().is_empty()
|
||||
{
|
||||
let _ = write!(out, "\n\nstdout:\n```\n{}\n```", stdout.trim());
|
||||
if let Some(ref stdout) = task.stdout_tail {
|
||||
if !stdout.trim().is_empty() {
|
||||
let _ = write!(out, "\n\nstdout:\n```\n{}\n```", stdout.trim());
|
||||
}
|
||||
}
|
||||
if let Some(ref stderr) = task.stderr_tail
|
||||
&& !stderr.trim().is_empty()
|
||||
{
|
||||
let _ = write!(out, "\n\nstderr:\n```\n{}\n```", stderr.trim());
|
||||
if let Some(ref stderr) = task.stderr_tail {
|
||||
if !stderr.trim().is_empty() {
|
||||
let _ = write!(out, "\n\nstderr:\n```\n{}\n```", stderr.trim());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -668,7 +667,9 @@ impl AgentServer {
|
|||
)]
|
||||
async fn get_loose_ends(&self, Parameters(args): Parameters<AgentGetLooseEndsArgs>) -> String {
|
||||
run_tool_envelope("get_loose_ends", String::new(), async move {
|
||||
let (resp, retries) = self.dispatch(hive_sh4re::AgentRequest::GetLooseEnds { agent: args.agent }).await;
|
||||
let (resp, retries) = self
|
||||
.dispatch(hive_sh4re::AgentRequest::GetLooseEnds { agent: args.agent })
|
||||
.await;
|
||||
let mut out = annotate_retries(format_loose_ends(resp), retries);
|
||||
// Append any local bash tasks still in pending/running state so
|
||||
// the agent sees all outstanding work in one call.
|
||||
|
|
@ -678,8 +679,11 @@ impl AgentServer {
|
|||
let _ = write!(out, "\n\n{} active bash task(s):", active.len());
|
||||
for task in &active {
|
||||
let age = crate::serve_common::now_unix() - task.created_at;
|
||||
let _ = write!(out, "\n- `{}` status={:?}, cmd: `{}`, age {}s",
|
||||
task.id, task.status, task.cmd, age);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\n- `{}` status={:?}, cmd: `{}`, age {}s",
|
||||
task.id, task.status, task.cmd, age
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
@ -830,9 +834,11 @@ impl AgentServer {
|
|||
)]
|
||||
async fn bash_status(&self, Parameters(args): Parameters<BashStatusArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
run_tool_envelope("bash_status", log, async move {
|
||||
format_bash_status(&args.id)
|
||||
})
|
||||
run_tool_envelope(
|
||||
"bash_status",
|
||||
log,
|
||||
async move { format_bash_status(&args.id) },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
|
|
@ -875,10 +881,7 @@ impl AgentServer {
|
|||
`since`: show entries on or newer than this (e.g. `-1h`, `2024-01-01 12:00:00`). \
|
||||
`until`: show entries on or older than this."
|
||||
)]
|
||||
async fn get_host_journal(
|
||||
&self,
|
||||
Parameters(args): Parameters<GetHostJournalArgs>,
|
||||
) -> String {
|
||||
async fn get_host_journal(&self, Parameters(args): Parameters<GetHostJournalArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
run_tool_envelope("get_host_journal", log, async move {
|
||||
let (resp, retries) = self
|
||||
|
|
@ -1913,14 +1916,14 @@ pub enum Flavor {
|
|||
}
|
||||
|
||||
/// Env var written by the meta renderer with a comma-separated list of
|
||||
/// `hive_sh4re::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`).
|
||||
/// `hive_sh4re::ToolGroup` snake_case names (e.g. `"messaging,inbox,meta"`).
|
||||
/// When present, the harness expands the groups into per-tool allow entries
|
||||
/// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`.
|
||||
const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
|
||||
|
||||
/// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the
|
||||
/// operator grants capabilities to this agent. Comma-separated
|
||||
/// `hive_sh4re::Capability` `snake_case` names. Absent = no extra capabilities.
|
||||
/// `hive_sh4re::Capability` snake_case names. Absent = no extra capabilities.
|
||||
const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES";
|
||||
|
||||
/// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are
|
||||
|
|
@ -1976,12 +1979,13 @@ fn effective_tool_groups(flavor: Flavor) -> Vec<hive_sh4re::ToolGroup> {
|
|||
for token in raw.split(',') {
|
||||
let t = token.trim().to_ascii_lowercase();
|
||||
// Parse via serde_json (the canonical deserialization path).
|
||||
if let Ok(g) = serde_json::from_value::<hive_sh4re::ToolGroup>(
|
||||
serde_json::Value::String(t.clone()),
|
||||
) {
|
||||
groups.push(g);
|
||||
} else {
|
||||
tracing::warn!(token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping");
|
||||
match serde_json::from_value::<hive_sh4re::ToolGroup>(serde_json::Value::String(t.clone()))
|
||||
{
|
||||
Ok(g) => groups.push(g),
|
||||
Err(_) => tracing::warn!(
|
||||
token = %t,
|
||||
"{TOOL_GROUPS_ENV}: unknown tool group, skipping"
|
||||
),
|
||||
}
|
||||
}
|
||||
if groups.is_empty() {
|
||||
|
|
|
|||
|
|
@ -391,7 +391,11 @@ fn summarize_durations(all: &mut [i64]) -> DurationSummary {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
#[allow(
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss
|
||||
)]
|
||||
fn percentile(sorted: &[i64], pct: u8) -> f64 {
|
||||
if sorted.is_empty() {
|
||||
return 0.0;
|
||||
|
|
@ -471,7 +475,15 @@ mod tests {
|
|||
(started_at, ended_at, duration_ms, model, wake_from,
|
||||
last_input_tokens, tool_call_breakdown_json, result_kind)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 1000, ?6, ?7)",
|
||||
params![started, started + dur / 1000, dur, model, wake, tools_json, result],
|
||||
params![
|
||||
started,
|
||||
started + dur / 1000,
|
||||
dur,
|
||||
model,
|
||||
wake,
|
||||
tools_json,
|
||||
result
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
|
@ -485,7 +497,14 @@ mod tests {
|
|||
seed_db(
|
||||
&db,
|
||||
&[
|
||||
(now - 600, 5_000, "opus", "recv", "ok", r#"{"Read":2,"Bash":1}"#),
|
||||
(
|
||||
now - 600,
|
||||
5_000,
|
||||
"opus",
|
||||
"recv",
|
||||
"ok",
|
||||
r#"{"Read":2,"Bash":1}"#,
|
||||
),
|
||||
(now - 300, 10_000, "opus", "recv", "ok", r#"{"Read":3}"#),
|
||||
(now - 100, 20_000, "sonnet", "operator", "failed", "{}"),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -561,7 +561,9 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
|||
ctx_usage,
|
||||
cost_usage,
|
||||
links: agent_links(&state.label, state.gui_vnc_port.is_some()),
|
||||
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL").ok().filter(|s| !s.is_empty()),
|
||||
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
hive_name: crate::identity::hive_name(),
|
||||
swarm_name: crate::identity::swarm_name(),
|
||||
})
|
||||
|
|
@ -613,8 +615,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
|||
// `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk
|
||||
// is `{label, icon, url}` with absolute URLs — those become
|
||||
// `kind = External` links, passed through verbatim.
|
||||
let extras_path =
|
||||
crate::paths::state_dir().join("hyperhive-dashboard-links.json");
|
||||
let extras_path = crate::paths::state_dir().join("hyperhive-dashboard-links.json");
|
||||
if let Ok(text) = std::fs::read_to_string(&extras_path)
|
||||
&& !text.trim().is_empty()
|
||||
&& let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text)
|
||||
|
|
@ -662,7 +663,10 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::InboxRow> {
|
|||
/// Fetch reminder activity stats from the broker via the per-agent /
|
||||
/// manager socket. Returns None on any transport / decode failure — the
|
||||
/// stats are decorative, not authoritative.
|
||||
async fn fetch_reminder_stats(socket: &std::path::Path, window_secs: u64) -> Option<hive_sh4re::ReminderStats> {
|
||||
async fn fetch_reminder_stats(
|
||||
socket: &std::path::Path,
|
||||
window_secs: u64,
|
||||
) -> Option<hive_sh4re::ReminderStats> {
|
||||
match client::request::<_, hive_sh4re::Response>(
|
||||
socket,
|
||||
&hive_sh4re::Request::ReminderRollup {
|
||||
|
|
@ -956,7 +960,9 @@ async fn post_cancel_turn(State(state): State<AppState>) -> Response {
|
|||
),
|
||||
Err(e) => format!("operator: /cancel — pkill failed: {e}"),
|
||||
};
|
||||
state.bus.emit(crate::events::LiveEvent::Note { text: note });
|
||||
state
|
||||
.bus
|
||||
.emit(crate::events::LiveEvent::Note { text: note });
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue