hive-c0re: annotate remaining dashboard routes with utoipa

This commit is contained in:
damocles 2026-07-31 23:00:12 +02:00 committed by mara
commit 582ebe5eee
21 changed files with 738 additions and 45 deletions

View file

@ -10,11 +10,23 @@ use axum::{
};
use hive_sh4re::Approval;
use serde::Deserialize;
use utoipa::ToSchema;
use super::{AppState, error_response};
use crate::actions;
use crate::coordinator::Coordinator;
/// `POST /api/approve/{id}` — approve a pending approval row.
#[utoipa::path(
post,
path = "/api/approve/{id}",
params(("id" = i64, Path, description = "approval row id")),
responses(
(status = 200, description = "approved", body = String),
(status = 500, description = "approve failed"),
),
tag = "approvals"
)]
pub(super) async fn post_approve(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
@ -29,12 +41,25 @@ pub(super) async fn post_approve(
}
}
#[derive(Deserialize, Default)]
#[derive(Deserialize, Default, ToSchema)]
pub(super) struct DenyForm {
#[serde(default)]
note: Option<String>,
}
/// `POST /api/deny/{id}` — deny a pending approval row, with an optional
/// note (form field `note`).
#[utoipa::path(
post,
path = "/api/deny/{id}",
params(("id" = i64, Path, description = "approval row id")),
request_body(content = DenyForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "denied", body = String),
(status = 500, description = "deny failed"),
),
tag = "approvals"
)]
pub(super) async fn post_deny(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,

View file

@ -17,10 +17,12 @@ use axum::{
use serde::{Deserialize, Serialize};
use tokio_stream::Stream;
use tokio_stream::wrappers::ReceiverStream;
use utoipa::IntoParams;
use super::{AppState, Ident, error_response};
use crate::build_logs::{BuildLogFull, BuildLogHeader};
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct BuildLogsAllQuery {
/// Max rows to return. Capped at 100. Default 30.
#[serde(default)]
@ -29,6 +31,16 @@ pub(super) struct BuildLogsAllQuery {
/// `GET /api/build-logs?limit=N` — most-recent build log headers across
/// all agents, newest first. Same JSON shape as the per-agent endpoint.
#[utoipa::path(
get,
path = "/api/build-logs",
params(BuildLogsAllQuery),
responses(
(status = 200, description = "recent build log headers, newest first", body = Vec<BuildLogHeader>),
(status = 500, description = "sqlite read failed"),
),
tag = "build_logs"
)]
pub(super) async fn get_build_logs_all(
State(state): State<AppState>,
axum::extract::Query(q): axum::extract::Query<BuildLogsAllQuery>,
@ -40,7 +52,7 @@ pub(super) async fn get_build_logs_all(
}
}
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct BuildLogsQuery {
/// Maximum number of rows to return. Capped server-side at 50
/// (see `build_logs::list_recent_for_agent`). Default 10.
@ -53,6 +65,20 @@ pub(super) struct BuildLogsQuery {
/// `Vec<BuildLogHeader>` (JSON). Limit defaults to 10, server-side
/// cap at 50. Backs the per-agent log chip in the agent card and
/// the side-panel header list.
#[utoipa::path(
get,
path = "/api/build-logs/{agent}",
params(
("agent" = String, Path, description = "agent name"),
BuildLogsQuery,
),
responses(
(status = 200, description = "recent build log headers for the agent, newest first", body = Vec<BuildLogHeader>),
(status = 400, description = "bad agent name"),
(status = 500, description = "sqlite read failed"),
),
tag = "build_logs"
)]
pub(super) async fn get_build_logs_agent(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -79,6 +105,17 @@ pub(super) async fn get_build_logs_agent(
/// stderr concatenated) by id. Returns `BuildLogFull` (JSON), or
/// HTTP 404 when the id doesn't exist (vacuum-reaped, or the
/// operator passed a stale id from a refresh race).
#[utoipa::path(
get,
path = "/api/build-logs/id/{id}",
params(("id" = i64, Path, description = "build log row id")),
responses(
(status = 200, description = "full build log row", body = BuildLogFull),
(status = 404, description = "no such build log row"),
(status = 500, description = "sqlite read failed"),
),
tag = "build_logs"
)]
pub(super) async fn get_build_log_full(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
@ -96,6 +133,17 @@ pub(super) async fn get_build_log_full(
/// node has no linked log (the client gates the request on `NodeView.has_log`,
/// but a vacuum race can still 404). This is the on-demand log fetch the
/// raw-graph dashboard uses instead of an inline `build_log_id` on the wire.
#[utoipa::path(
get,
path = "/api/build-log/{node_id}",
params(("node_id" = u64, Path, description = "job-queue node id")),
responses(
(status = 200, description = "full build log row for the node's linked log", body = BuildLogFull),
(status = 404, description = "node has no linked build log, or the log row is gone"),
(status = 500, description = "sqlite read failed"),
),
tag = "build_logs"
)]
pub(super) async fn get_build_log_for_node(
State(state): State<AppState>,
AxumPath(node_id): AxumPath<u64>,
@ -112,6 +160,17 @@ pub(super) async fn get_build_log_for_node(
/// `GET /api/build-log/{node_id}/raw` — the node's build log as `text/plain`
/// for download (delegates to `get_build_log_raw` after resolving the node id).
#[utoipa::path(
get,
path = "/api/build-log/{node_id}/raw",
params(("node_id" = u64, Path, description = "job-queue node id")),
responses(
(status = 200, description = "build log text for download", body = String, content_type = "text/plain"),
(status = 404, description = "node has no linked build log, or the log row is gone"),
(status = 500, description = "sqlite read failed"),
),
tag = "build_logs"
)]
pub(super) async fn get_build_log_raw_for_node(
State(state): State<AppState>,
AxumPath(node_id): AxumPath<u64>,
@ -239,6 +298,17 @@ pub(super) async fn get_build_log_stream(
/// separator (same layout the JS side-panel renders). The
/// `Content-Disposition` header triggers a browser download with a
/// descriptive filename so the operator can save and share the log.
#[utoipa::path(
get,
path = "/api/build-logs/id/{id}/raw",
params(("id" = i64, Path, description = "build log row id")),
responses(
(status = 200, description = "build log text for download", body = String, content_type = "text/plain"),
(status = 404, description = "no such build log row"),
(status = 500, description = "sqlite read failed"),
),
tag = "build_logs"
)]
pub(super) async fn get_build_log_raw(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,

View file

@ -22,6 +22,7 @@ use std::path::Path;
use axum::extract::{Form, Query};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use super::{Ident, error_response};
use crate::coordinator::Coordinator;
@ -41,18 +42,18 @@ fn read_base_url(dir: &Path, label: &str) -> Option<String> {
.map(|s| s.base_url)
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
struct ExtraForgeAccount {
label: String,
base_url: Option<String>,
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
struct ExtraForgesResponse {
forges: Vec<ExtraForgeAccount>,
}
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct ExtraForgesQuery {
agent: String,
}
@ -62,6 +63,16 @@ pub(super) struct ExtraForgesQuery {
/// token` file in its state dir (mirrors `matrix_accounts.rs`'s filename-scan
/// listing). `base_url` is backfilled from the matching `forge-<label>.json`
/// sidecar when present. Never returns a token.
#[utoipa::path(
get,
path = "/api/extra-forges",
params(ExtraForgesQuery),
responses(
(status = 200, description = "provisioned extra forge accounts for the agent", body = ExtraForgesResponse),
(status = 500, description = "invalid agent name, or a state-dir read failed"),
),
tag = "extra_forges"
)]
pub(super) async fn get_extra_forges(Query(q): Query<ExtraForgesQuery>) -> Response {
let agent = q.agent.trim();
let Ok(agent) = Ident::parse(agent) else {
@ -108,7 +119,7 @@ pub(super) async fn get_extra_forges(Query(q): Query<ExtraForgesQuery>) -> Respo
/// Form body for `POST /api/extra-forge-account` (urlencoded, the
/// dashboard's mutation convention). `action` is `"add"` (needs `base_url` +
/// `token`) or `"remove"`.
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct ExtraForgeAccountForm {
agent: String,
label: String,
@ -117,7 +128,7 @@ pub(super) struct ExtraForgeAccountForm {
token: Option<String>,
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
struct ExtraForgeAccountResult {
ok: bool,
}
@ -127,6 +138,16 @@ struct ExtraForgeAccountResult {
/// deletes both files. Purely local — no remote account creation or
/// revocation, there is no admin access assumed on the external forge.
/// Operator-authenticated (dashboard). Never echoes the token back.
#[utoipa::path(
post,
path = "/api/extra-forge-account",
request_body(content = ExtraForgeAccountForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "provisioned or removed", body = ExtraForgeAccountResult),
(status = 500, description = "invalid input, or the state-dir write/delete failed"),
),
tag = "extra_forges"
)]
pub(super) async fn post_extra_forge_account(Form(f): Form<ExtraForgeAccountForm>) -> Response {
let agent = f.agent.trim();
let label = f.label.trim();

View file

@ -22,6 +22,19 @@ use super::{AppState, error_response};
/// `restart_infra`) and streams as an `AuditEntryAdded` event, so
/// operator-driven and agent-driven (`infra_admin`) infra actions show up
/// in the same AUDIT view.
#[utoipa::path(
post,
path = "/api/infra-container/{name}/{action}",
params(
("name" = String, Path, description = "infra container name (hive-ci/hive-forge/hive-gateway/hive-matrix)"),
("action" = String, Path, description = "start | stop | restart"),
),
responses(
(status = 200, description = "action completed", body = String),
(status = 500, description = "unknown container/action, or the systemd action failed"),
),
tag = "infra_containers"
)]
pub(super) async fn post_infra_container(
State(state): State<AppState>,
AxumPath((name, action)): AxumPath<(String, String)>,

View file

@ -14,12 +14,13 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Deserialize;
use utoipa::{IntoParams, ToSchema};
/// Query params for `post_kill` / `post_restart`. `?graceful=1` routes to
/// the graceful-stop/-restart orchestration (quiesce the harness, flush
/// `/state`, then container stop/restart) instead of an immediate hard
/// action. Defaults false → today's hard kill/restart.
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct GracefulParams {
#[serde(default)]
graceful: bool,
@ -29,6 +30,18 @@ use super::{AppState, Ident, error_response, guard_agent_name, strip_container_p
use crate::job_queue::{Source, submit};
use crate::{actions, lifecycle};
/// `POST /api/rebuild/{name}` — queue a rebuild DAG for `name`.
#[utoipa::path(
post,
path = "/api/rebuild/{name}",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "rebuild queued", body = String),
(status = 400, description = "bad agent name"),
(status = 404, description = "no such agent"),
),
tag = "lifecycle_ops"
)]
pub(super) async fn post_rebuild(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -46,6 +59,22 @@ pub(super) async fn post_rebuild(
(StatusCode::OK, "ok").into_response()
}
/// `POST /api/kill/{name}?graceful=1` — stop `name`, hard by default or
/// gracefully (quiesce → drain → stop) when `graceful=1`.
#[utoipa::path(
post,
path = "/api/kill/{name}",
params(
("name" = String, Path, description = "agent name"),
GracefulParams,
),
responses(
(status = 200, description = "stop queued/performed", body = String),
(status = 400, description = "bad agent name"),
(status = 404, description = "no such agent"),
),
tag = "lifecycle_ops"
)]
pub(super) async fn post_kill(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -90,6 +119,22 @@ pub(super) async fn post_kill(
(StatusCode::OK, "ok").into_response()
}
/// `POST /api/restart/{name}?graceful=1` — restart `name`, hard by default
/// or gracefully (quiesce → drain → restart) when `graceful=1`.
#[utoipa::path(
post,
path = "/api/restart/{name}",
params(
("name" = String, Path, description = "agent name"),
GracefulParams,
),
responses(
(status = 200, description = "restart queued/performed", body = String),
(status = 400, description = "bad agent name"),
(status = 404, description = "no such agent"),
),
tag = "lifecycle_ops"
)]
pub(super) async fn post_restart(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -119,6 +164,18 @@ pub(super) async fn post_restart(
(StatusCode::OK, "ok").into_response()
}
/// `POST /api/start/{name}` — start `name`.
#[utoipa::path(
post,
path = "/api/start/{name}",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "start queued", body = String),
(status = 400, description = "bad agent name"),
(status = 404, description = "no such agent"),
),
tag = "lifecycle_ops"
)]
pub(super) async fn post_start(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -145,6 +202,18 @@ pub(super) async fn post_start(
/// when the container next boots). Triggers an immediate rescan so the
/// `paused` badge flips on the dashboard without waiting for the next
/// periodic sweep.
#[utoipa::path(
post,
path = "/api/pause/{name}",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "pause marker written", body = String),
(status = 400, description = "bad agent name"),
(status = 404, description = "no such agent"),
(status = 500, description = "marker write failed"),
),
tag = "lifecycle_ops"
)]
pub(super) async fn post_pause(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -168,6 +237,18 @@ pub(super) async fn post_pause(
///
/// The inverse of `post_pause`. Removing a non-existent marker is a no-op
/// (idempotent). Triggers an immediate rescan so the paused badge clears.
#[utoipa::path(
post,
path = "/api/resume/{name}",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "pause marker removed", body = String),
(status = 400, description = "bad agent name"),
(status = 404, description = "no such agent"),
(status = 500, description = "marker removal failed"),
),
tag = "lifecycle_ops"
)]
pub(super) async fn post_resume(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -190,7 +271,7 @@ pub(super) async fn post_resume(
/// Form fields for `post_resource_limits`. Both fields are optional strings;
/// an empty value clears the per-agent override for that field, falling back
/// to the hive-wide default.
#[derive(Deserialize, Default)]
#[derive(Deserialize, Default, ToSchema)]
pub(super) struct ResourceLimitsForm {
#[serde(default)]
cpu_quota: String,
@ -207,6 +288,20 @@ pub(super) struct ResourceLimitsForm {
/// limits take effect on the next container start or restart. Triggers an
/// immediate rescan so `ContainerView.cpu_quota`/`memory_max` update on
/// the dashboard via SSE without waiting for the next periodic sweep.
#[utoipa::path(
post,
path = "/api/resource-limits/{name}",
params(("name" = String, Path, description = "agent name")),
request_body(content = ResourceLimitsForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "limits written", body = String),
(status = 400, description = "bad agent name"),
(status = 404, description = "no such agent"),
(status = 422, description = "invalid cpu_quota/memory_max value"),
(status = 500, description = "commit or drop-in write failed"),
),
tag = "lifecycle_ops"
)]
pub(super) async fn post_resource_limits(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -257,6 +352,14 @@ pub(super) async fn post_resource_limits(
(StatusCode::OK, "ok").into_response()
}
/// `POST /api/update-all` — queue a rebuild DAG for every live agent
/// container.
#[utoipa::path(
post,
path = "/api/update-all",
responses((status = 200, description = "rebuilds queued", body = String)),
tag = "lifecycle_ops"
)]
pub(super) async fn post_update_all(State(state): State<AppState>) -> Response {
let containers = lifecycle::list().await.unwrap_or_default();
for container in containers {
@ -276,12 +379,28 @@ pub(super) async fn post_update_all(State(state): State<AppState>) -> Response {
(StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize, Default)]
#[derive(Deserialize, Default, ToSchema)]
pub(super) struct DestroyForm {
#[serde(default)]
purge: Option<String>,
}
/// `POST /api/destroy/{name}` — destroy `name`'s container. Form field
/// `purge` (any non-empty value, e.g. `"on"`) also wipes the retained
/// state dir instead of leaving a tombstone.
#[utoipa::path(
post,
path = "/api/destroy/{name}",
params(("name" = String, Path, description = "agent name")),
request_body(content = DestroyForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "destroyed", body = String),
(status = 400, description = "bad agent name"),
(status = 404, description = "no such agent"),
(status = 500, description = "destroy failed"),
),
tag = "lifecycle_ops"
)]
pub(super) async fn post_destroy(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,

View file

@ -22,16 +22,17 @@ use std::path::Path;
use axum::extract::{Form, Query};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use super::{Ident, error_response};
use crate::coordinator::Coordinator;
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct MatrixAccountsQuery {
agent: String,
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
struct MatrixAccount {
name: String,
/// Effective homeserver, backfilled from the daemon snapshot; `None` when
@ -46,7 +47,7 @@ struct MatrixAccount {
user_id: Option<String>,
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
struct MatrixAccountsResponse {
accounts: Vec<MatrixAccount>,
/// Unix mtime of the daemon's `matrix-accounts.json` snapshot (when the
@ -102,6 +103,19 @@ fn account_name_from_filename(fname: &str) -> Option<String> {
Some(suffix.to_owned())
}
/// `GET /api/matrix-accounts?agent=<name>` — matrix accounts provisioned
/// for `agent`, backfilled with homeserver/live/user_id from the daemon's
/// snapshot.
#[utoipa::path(
get,
path = "/api/matrix-accounts",
params(MatrixAccountsQuery),
responses(
(status = 200, description = "provisioned matrix accounts for the agent", body = MatrixAccountsResponse),
(status = 500, description = "invalid agent name, or a state-dir read failed"),
),
tag = "matrix_accounts"
)]
pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) -> Response {
let agent = q.agent.trim();
// Validate through the single `Ident` type so a crafted `agent` can't
@ -156,7 +170,7 @@ pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) ->
/// mutation convention). `mode` is `"password"` (needs `user_id` +
/// `password`) or `"token"` (needs `token`; `user_id` is recovered via
/// whoami).
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct MatrixLoginForm {
agent: String,
account: String,
@ -167,7 +181,7 @@ pub(super) struct MatrixLoginForm {
token: Option<String>,
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
struct MatrixLoginResult {
ok: bool,
user_id: String,
@ -178,6 +192,16 @@ struct MatrixLoginResult {
/// On success writes the token to `matrix-token-<account>` via hive-priv and
/// kicks the daemon. Operator-authenticated (dashboard). Never echoes the
/// token back — only `{ ok, user_id }`.
#[utoipa::path(
post,
path = "/api/matrix-account-login",
request_body(content = MatrixLoginForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "account provisioned", body = MatrixLoginResult),
(status = 500, description = "invalid input, or the homeserver login/whoami failed"),
),
tag = "matrix_accounts"
)]
pub(super) async fn post_matrix_account_login(Form(f): Form<MatrixLoginForm>) -> Response {
let agent = f.agent.trim();
let account = f.account.trim();
@ -257,13 +281,13 @@ pub(super) async fn post_matrix_account_login(Form(f): Form<MatrixLoginForm>) ->
/// `github-token` file. The GitHub counterpart of the matrix login form, but
/// far simpler: no account creation, no homeserver, no login modes — the
/// operator pastes a PAT for an existing account.
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct GithubAccountForm {
agent: String,
token: String,
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
struct GithubAccountResult {
ok: bool,
}
@ -275,6 +299,16 @@ struct GithubAccountResult {
/// helper read the file live, so the new token takes effect immediately.
/// Operator-authenticated (dashboard). Never echoes the token back — only
/// `{ ok: true }`.
#[utoipa::path(
post,
path = "/api/github-account",
request_body(content = GithubAccountForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "PAT provisioned", body = GithubAccountResult),
(status = 500, description = "invalid agent name, empty token, or the write failed"),
),
tag = "matrix_accounts"
)]
pub(super) async fn post_github_account(Form(f): Form<GithubAccountForm>) -> Response {
let agent = f.agent.trim();
let token = f.token.trim();
@ -291,12 +325,12 @@ pub(super) async fn post_github_account(Form(f): Form<GithubAccountForm>) -> Res
axum::Json(GithubAccountResult { ok: true }).into_response()
}
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct GithubAccountQuery {
agent: String,
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
struct GithubAccountStatus {
/// A `github-token` file exists in the agent's state dir (a PAT has been
/// provisioned). A static PAT has no live/heartbeat concept, so this is
@ -308,6 +342,16 @@ struct GithubAccountStatus {
/// PAT provisioned (its `github-token` file exists). Lets the credentials tab
/// show "token stored" vs "not set" instead of a black-hole paste field.
/// Never returns the token itself.
#[utoipa::path(
get,
path = "/api/github-account",
params(GithubAccountQuery),
responses(
(status = 200, description = "whether a github PAT is provisioned", body = GithubAccountStatus),
(status = 500, description = "invalid agent name"),
),
tag = "matrix_accounts"
)]
pub(super) async fn get_github_account(Query(q): Query<GithubAccountQuery>) -> Response {
let agent = q.agent.trim();
let Ok(agent) = Ident::parse(agent) else {

View file

@ -9,6 +9,7 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::coordinator::Coordinator;
@ -172,7 +173,7 @@ pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) {
/// list under the `inputs` field — the JS submitter joins the
/// checked boxes since axum's `Form` extractor doesn't natively
/// decode repeated keys without a helper.
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct MetaUpdateForm {
inputs: String,
}
@ -183,6 +184,16 @@ pub(super) struct MetaUpdateForm {
/// no rebuild ripple). Returns immediately after queueing the work;
/// dashboard polls for progress via container `pending` spinners +
/// the meta-inputs row sha update.
#[utoipa::path(
post,
path = "/api/meta-update",
request_body(content = MetaUpdateForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "update + rebuild ripple queued", body = String),
(status = 500, description = "no inputs selected"),
),
tag = "meta_inputs"
)]
pub(super) async fn post_meta_update(
State(state): State<AppState>,
Form(form): Form<MetaUpdateForm>,

View file

@ -9,8 +9,11 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Deserialize;
use utoipa::{IntoParams, ToSchema};
use super::{AppState, Ident, error_response, scan_validated_paths};
use crate::container_stats::ContainerResource;
use crate::hive_stats::HiveStats;
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox.
/// Returns messages addressed to `"operator"` that haven't been
@ -19,6 +22,15 @@ use super::{AppState, Ident, error_response, scan_validated_paths};
/// tokens are validated so the client renders file links like the
/// terminal does. Shape: `{ "messages": [{ id, from, body, at,
/// in_reply_to, file_refs }] }`.
#[utoipa::path(
get,
path = "/api/operator-inbox",
responses(
(status = 200, description = "unread operator-directed messages", body = serde_json::Value),
(status = 500, description = "broker read failed"),
),
tag = "misc_api"
)]
pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Response {
const INBOX_LIMIT: u64 = 100;
match state
@ -58,7 +70,7 @@ pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Respons
}
}
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct StatsHiveQuery {
window: Option<String>,
}
@ -66,6 +78,13 @@ pub(super) struct StatsHiveQuery {
/// Hive-wide turn-stats rollup for the dashboard swarm-stats view.
/// Aggregates every agent's `hyperhive-turn-stats.sqlite` read-only
/// (skips missing/unreadable ones). Window defaults to `24h`.
#[utoipa::path(
get,
path = "/api/stats-hive",
params(StatsHiveQuery),
responses((status = 200, description = "hive-wide turn-stats rollup", body = HiveStats)),
tag = "misc_api"
)]
pub(super) async fn api_stats_hive(
State(state): State<AppState>,
axum::extract::Query(q): axum::extract::Query<StatsHiveQuery>,
@ -80,6 +99,12 @@ pub(super) async fn api_stats_hive(
/// Live per-agent-container CPU + memory load from cgroup v2. Samples
/// CPU over a short interval (~200 ms), so this call briefly awaits.
#[utoipa::path(
get,
path = "/api/container-resources",
responses((status = 200, description = "live per-container CPU + memory load", body = Vec<ContainerResource>)),
tag = "misc_api"
)]
pub(super) async fn api_container_resources() -> Response {
axum::Json(crate::container_stats::gather().await).into_response()
}
@ -90,6 +115,15 @@ pub(super) async fn api_container_resources() -> Response {
/// `{ "entries": [AuditEntry…], "total": N }` so the UI can show
/// "latest 500 of N" rather than silently capping. `ts_unix` is in
/// **seconds**.
#[utoipa::path(
get,
path = "/api/audit-log",
responses(
(status = 200, description = "recent audit entries + total count", body = serde_json::Value),
(status = 500, description = "sqlite read failed"),
),
tag = "misc_api"
)]
pub(super) async fn api_audit_log(State(state): State<AppState>) -> Response {
const LIMIT: usize = 500;
let entries = match state.coord.audit_log.list_recent(LIMIT) {
@ -109,6 +143,17 @@ pub(super) async fn api_audit_log(State(state): State<AppState>) -> Response {
/// rows so vacuum can collect them). Returns `{ "marked": N }` so the
/// frontend can show "cleared N messages" feedback without an extra
/// fetch.
#[utoipa::path(
post,
path = "/api/agent/{name}/mark-all-read",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "count of messages marked read", body = serde_json::Value),
(status = 400, description = "bad agent name"),
(status = 500, description = "broker write failed"),
),
tag = "misc_api"
)]
pub(super) async fn post_mark_all_read(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -135,12 +180,24 @@ pub(super) async fn post_mark_all_read(
/// validation that `to` resolves to a known agent — broker accepts
/// arbitrary recipients (and the agent's inbox grows whether or not
/// they exist, which is fine for spawn-then-greet flows).
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct OpSendForm {
to: String,
body: String,
}
/// `POST /api/op-send` — operator compose: drop a message into the
/// broker addressed to `to` (or `*` to broadcast).
#[utoipa::path(
post,
path = "/api/op-send",
request_body(content = OpSendForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "message sent", body = String),
(status = 500, description = "missing to/body, or the broker send failed"),
),
tag = "misc_api"
)]
pub(super) async fn post_op_send(
State(state): State<AppState>,
Form(form): Form<OpSendForm>,
@ -180,11 +237,22 @@ pub(super) async fn post_op_send(
(axum::http::StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct RequestSpawnForm {
name: String,
}
/// `POST /api/request-spawn` — queue a spawn approval for `name`.
#[utoipa::path(
post,
path = "/api/request-spawn",
request_body(content = RequestSpawnForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "spawn approval queued", body = String),
(status = 500, description = "missing name, or the approval submit failed"),
),
tag = "misc_api"
)]
pub(super) async fn post_request_spawn(
State(state): State<AppState>,
Form(form): Form<RequestSpawnForm>,

View file

@ -37,6 +37,22 @@ use crate::lifecycle;
tags(
(name = "health", description = "hive-wide liveness/readiness probes"),
(name = "journal", description = "container + host journal reads"),
(name = "approvals", description = "approve/deny pending approval rows"),
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "extra_forges", description = "external (non-internal) forge account provisioning"),
(name = "infra_containers", description = "start/stop/restart of hive infrastructure containers"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "matrix_accounts", description = "matrix + github account provisioning for agents"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats, audit log"),
(name = "permissions", description = "tool-group + capability assignment for agents"),
(name = "questions", description = "answer/cancel pending operator questions"),
(name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
(name = "tombstones", description = "purge of retained state for destroyed agents"),
(name = "topology", description = "operator-driven agent reparenting"),
(name = "webhook", description = "forgejo webhook receivers"),
)
)]
struct ApiDoc;
@ -423,3 +439,18 @@ mod tests {
assert_eq!(fv["status"], 500);
}
}
#[cfg(test)]
mod router_build_probe {
use super::*;
#[test]
fn probe_multi_path_routes_macro_does_not_panic() {
let _ = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi()).routes(routes!(
health::get_health_live,
health::get_health_ready,
journal::get_journal,
journal::get_journal_host,
));
}
}

View file

@ -11,12 +11,13 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use problem_details::ProblemDetails;
use super::{AppState, Ident, guard_agent_name, strip_container_prefix};
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
pub(super) struct ToolGroupsSnapshot {
/// Ordered list of all known tool-group names. Drives the column
/// headers in the capabilities table — the UI does not hard-code them.
@ -36,6 +37,14 @@ pub(super) struct ToolGroupsSnapshot {
effective: std::collections::BTreeMap<String, Vec<String>>,
}
/// `GET /api/tool-groups` — every known tool-group name + description,
/// plus the per-agent explicit/effective assignment maps.
#[utoipa::path(
get,
path = "/api/tool-groups",
responses((status = 200, description = "tool-group catalogue + assignments", body = ToolGroupsSnapshot)),
tag = "permissions"
)]
pub(super) async fn get_tool_groups(
State(state): State<AppState>,
) -> axum::Json<ToolGroupsSnapshot> {
@ -105,11 +114,25 @@ pub(crate) fn roster_and_effective(
(agents, effective)
}
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct SetToolGroupsBody {
groups: Vec<String>,
}
/// `POST /api/tool-groups/{agent}` — replace `agent`'s explicit tool-group
/// assignment (JSON body `{"groups": [...]}`).
#[utoipa::path(
post,
path = "/api/tool-groups/{agent}",
params(("agent" = String, Path, description = "agent name")),
request_body = SetToolGroupsBody,
responses(
(status = 200, description = "tool-groups queued for write", body = String),
(status = 400, description = "unknown group name, or no such agent"),
(status = 404, description = "no such agent"),
),
tag = "permissions"
)]
pub(super) async fn post_tool_groups(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -145,7 +168,7 @@ pub(super) async fn post_tool_groups(
Ok((StatusCode::OK, "ok").into_response())
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
pub(super) struct CapabilitiesSnapshot {
/// Ordered list of all known capability names. Drives the column
/// headers in the capabilities table — the UI does not hard-code them.
@ -164,6 +187,14 @@ pub(super) struct CapabilitiesSnapshot {
effective: std::collections::BTreeMap<String, Vec<String>>,
}
/// `GET /api/capabilities` — every known capability name + description,
/// plus the per-agent explicit/effective grant maps.
#[utoipa::path(
get,
path = "/api/capabilities",
responses((status = 200, description = "capability catalogue + assignments", body = CapabilitiesSnapshot)),
tag = "permissions"
)]
pub(super) async fn get_capabilities(
State(state): State<AppState>,
) -> axum::Json<CapabilitiesSnapshot> {
@ -191,11 +222,25 @@ pub(super) async fn get_capabilities(
})
}
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct SetCapabilitiesBody {
caps: Vec<String>,
}
/// `POST /api/capabilities/{agent}` — replace `agent`'s explicit capability
/// grant set (JSON body `{"caps": [...]}`).
#[utoipa::path(
post,
path = "/api/capabilities/{agent}",
params(("agent" = String, Path, description = "agent name")),
request_body = SetCapabilitiesBody,
responses(
(status = 200, description = "capabilities queued for write", body = String),
(status = 400, description = "unknown capability name"),
(status = 404, description = "no such agent"),
),
tag = "permissions"
)]
pub(super) async fn post_capabilities(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -236,7 +281,7 @@ pub(super) async fn post_capabilities(
/// field leaves that perm-type untouched, an empty array clears it, a
/// populated array fully replaces it (same replace semantics as the
/// per-agent endpoints).
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct PermChangeBody {
agent: String,
#[serde(default)]
@ -245,7 +290,7 @@ pub(super) struct PermChangeBody {
capabilities: Option<Vec<String>>,
}
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct BatchPermsBody {
changes: Vec<PermChangeBody>,
}
@ -261,6 +306,17 @@ type StagedPerm = (String, Option<Vec<String>>, Option<Vec<String>>);
/// groups both changed rebuilds once, not twice. The whole batch is
/// atomic: every change is validated up front and on any validation
/// error nothing is written or enqueued.
#[utoipa::path(
post,
path = "/api/permissions",
request_body = BatchPermsBody,
responses(
(status = 200, description = "batch permission change queued", body = String),
(status = 400, description = "unknown tool-group/capability name, or no such agent"),
(status = 404, description = "no such agent"),
),
tag = "permissions"
)]
pub(super) async fn post_permissions(
State(state): State<AppState>,
axum::Json(body): axum::Json<BatchPermsBody>,
@ -320,12 +376,20 @@ pub(super) async fn post_permissions(
/// persisted). The client uses this to drive the "stale permission entries"
/// sub-section in K3PT ST4T3 without having to fetch three separate
/// endpoints and perform set arithmetic on the client side.
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
pub(super) struct StalePermsResponse {
/// Ghost agent names, sorted. Empty list → no stale entries.
stale: Vec<String>,
}
/// `GET /api/permissions/stale` — agent names with explicit permission
/// entries but no matching live container or kept-state dir.
#[utoipa::path(
get,
path = "/api/permissions/stale",
responses((status = 200, description = "ghost agent names with stale permission entries", body = StalePermsResponse)),
tag = "permissions"
)]
pub(super) async fn get_stale_permissions(
State(state): State<AppState>,
) -> axum::Json<StalePermsResponse> {
@ -375,6 +439,17 @@ pub(super) async fn get_stale_permissions(
/// the format check ([`Ident::parse`]) is applied. No rebuild is
/// enqueued (the agent doesn't exist to rebuild); the SSE snapshots
/// update the P3RM1SS10NS tab live.
#[utoipa::path(
delete,
path = "/api/permissions/{agent}",
params(("agent" = String, Path, description = "agent name (need not be live)")),
responses(
(status = 200, description = "stale permission entries cleared", body = String),
(status = 400, description = "bad agent name"),
(status = 500, description = "tool-groups/capabilities file write failed"),
),
tag = "permissions"
)]
pub(super) async fn delete_agent_permissions(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,

View file

@ -12,12 +12,13 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Deserialize;
use utoipa::ToSchema;
use problem_details::ProblemDetails;
use super::{AppState, error_response};
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct AnswerForm {
answer: String,
}
@ -37,6 +38,20 @@ fn with_cors(resp: impl IntoResponse) -> Response {
resp
}
/// `POST /answer-question/{id}` — record the operator's answer and
/// notify the asker.
#[utoipa::path(
post,
path = "/api/answer-question/{id}",
params(("id" = i64, Path, description = "question row id")),
request_body(content = AnswerForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "answered", body = String),
(status = 400, description = "empty answer"),
(status = 500, description = "answer failed (already answered, unknown id, ...)"),
),
tag = "questions"
)]
pub(super) async fn post_answer_question(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
@ -85,6 +100,18 @@ pub(super) async fn post_answer_question(
/// so it can fall back on whatever default it had. Same code path as
/// a real answer — just lets the operator close the loop instead of
/// letting the question dangle forever.
/// `POST /cancel-question/{id}` — resolve a pending question with the
/// `[cancelled]` sentinel answer.
#[utoipa::path(
post,
path = "/api/cancel-question/{id}",
params(("id" = i64, Path, description = "question row id")),
responses(
(status = 200, description = "cancelled", body = String),
(status = 500, description = "cancel failed (already answered, unknown id, ...)"),
),
tag = "questions"
)]
pub(super) async fn post_cancel_question(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,

View file

@ -14,12 +14,25 @@ use axum::{
use problem_details::ProblemDetails;
use crate::scheduled_prompts::ScheduleNotFoundOrCancelled;
use crate::scheduled_prompts_worker::FireNowReport;
use super::{AppState, error_problem, error_response};
/// `GET /api/schedules` — snapshot of every schedule for the
/// scheduled-prompts tab. Returns the wire shape directly
/// so the frontend can render without an extra translation layer.
// `hive_sh4re::WireSchedule` (the actual body) has no `ToSchema` — adding
// one would pull `utoipa` into the wire-types crate for a single dashboard
// endpoint. `serde_json::Value` placeholder; see the batch report.
#[utoipa::path(
get,
path = "/api/schedules",
responses(
(status = 200, description = "every schedule, wire shape", body = Vec<serde_json::Value>),
(status = 500, description = "sqlite read failed"),
),
tag = "schedules"
)]
pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
match state.coord.scheduled_prompts.list() {
Ok(rows) => {
@ -50,6 +63,20 @@ pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
/// skips the approval gate — the operator click *is* the
/// approval. The schedule lands directly with
/// `source = Operator` and the worker picks it up at fire time.
#[utoipa::path(
post,
path = "/api/schedules",
// `hive_sh4re::SchedulePromptPayload` (the actual body) has no `ToSchema` —
// same reasoning as the `Vec<serde_json::Value>` placeholder on
// `api_schedules` above.
request_body(content = serde_json::Value, description = "SchedulePromptPayload wire shape"),
responses(
(status = 200, description = "created; body carries the new row id", body = serde_json::Value),
(status = 400, description = "no targets, empty body, or interval_seconds == 0"),
(status = 500, description = "submit failed"),
),
tag = "schedules"
)]
pub(super) async fn post_schedule_new(
State(state): State<AppState>,
axum::Json(payload): axum::Json<hive_sh4re::SchedulePromptPayload>,
@ -86,7 +113,7 @@ pub(super) async fn post_schedule_new(
/// Optional JSON body for `fire-now`. Absent / empty body ⇒
/// `reset_timer = false` (back-compat: cadence stays intact).
#[derive(serde::Deserialize, Default)]
#[derive(serde::Deserialize, Default, utoipa::ToSchema)]
pub(super) struct FireNowBody {
#[serde(default)]
reset_timer: bool,
@ -100,6 +127,17 @@ pub(super) struct FireNowBody {
/// wrong." For recurring schedules the cadence stays intact unless
/// the body carries `{"reset_timer": true}`, in which case the
/// countdown is re-armed from now (`next_fire_at = now + interval`).
#[utoipa::path(
post,
path = "/api/schedules/{id}/fire-now",
params(("id" = i64, Path, description = "schedule row id")),
request_body(content = FireNowBody, description = "optional; absent body means reset_timer = false"),
responses(
(status = 200, description = "fired; per-target outcome counts", body = FireNowReport),
(status = 500, description = "schedule missing, cancelled, or fully drained"),
),
tag = "schedules"
)]
pub(super) async fn post_schedule_fire_now(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
@ -123,6 +161,13 @@ pub(super) async fn post_schedule_fire_now(
/// flip to Cancelled, `{"cancelled": false}` when the DAG was
/// Running / terminal / gone. On success a fresh `RebuildQueueChanged`
/// snapshot fires so the state flip surfaces live.
#[utoipa::path(
post,
path = "/api/rebuild-queue/{id}/cancel",
params(("id" = u64, Path, description = "job-queue DAG id")),
responses((status = 200, description = "whether the DAG was cancelled", body = serde_json::Value)),
tag = "schedules"
)]
pub(super) async fn post_rebuild_queue_cancel(
State(state): State<AppState>,
AxumPath(id): AxumPath<u64>,
@ -137,14 +182,14 @@ pub(super) async fn post_rebuild_queue_cancel(
}
}
#[derive(serde::Deserialize, Default)]
#[derive(serde::Deserialize, Default, utoipa::ToSchema)]
pub(super) struct CancelScheduleForm {
/// `None` / absent / empty array → cancel whole schedule.
#[serde(default)]
targets: Option<Vec<String>>,
}
#[derive(serde::Deserialize, Default)]
#[derive(serde::Deserialize, Default, utoipa::ToSchema)]
#[allow(
clippy::option_option,
reason = "double-Option carries three-state PATCH semantics on the wire \
@ -203,6 +248,16 @@ where
/// `interval_seconds`. Cancelled schedules are refused — submit
/// a new one instead. Returns the updated `WireSchedule` so the
/// caller's post-edit refresh has the new state inline.
#[utoipa::path(
patch,
path = "/api/schedules/{id}",
params(("id" = i64, Path, description = "schedule row id")),
responses(
(status = 200, description = "updated, wire shape", body = serde_json::Value),
(status = 500, description = "update failed (cancelled, not found, ...)"),
),
tag = "schedules"
)]
pub(super) async fn patch_schedule(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
@ -233,6 +288,17 @@ pub(super) async fn patch_schedule(
/// `POST /api/schedules/{id}/pause` — pause a schedule so the worker
/// skips it until explicitly resumed. Idempotent; no-op on an already-
/// paused row. Returns 404 when the schedule is cancelled or not found.
#[utoipa::path(
post,
path = "/api/schedules/{id}/pause",
params(("id" = i64, Path, description = "schedule row id")),
responses(
(status = 200, description = "paused", body = String),
(status = 404, description = "schedule cancelled or not found"),
(status = 500, description = "pause failed"),
),
tag = "schedules"
)]
pub(super) async fn post_schedule_pause(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
@ -255,6 +321,17 @@ pub(super) async fn post_schedule_pause(
/// `POST /api/schedules/{id}/resume` — resume a paused schedule.
/// Idempotent; no-op on an already-active row. Returns 404 when the
/// schedule is cancelled or not found.
#[utoipa::path(
post,
path = "/api/schedules/{id}/resume",
params(("id" = i64, Path, description = "schedule row id")),
responses(
(status = 200, description = "resumed", body = String),
(status = 404, description = "schedule cancelled or not found"),
(status = 500, description = "resume failed"),
),
tag = "schedules"
)]
pub(super) async fn post_schedule_resume(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
@ -278,6 +355,16 @@ pub(super) async fn post_schedule_resume(
/// (whole schedule when no `targets` field, partial when one is
/// provided). Operator bypasses the topology check; the manager
/// surface enforces it for agent callers.
#[utoipa::path(
post,
path = "/api/schedules/{id}/cancel",
params(("id" = i64, Path, description = "schedule row id")),
responses(
(status = 200, description = "cancelled (whole or partial)", body = String),
(status = 500, description = "cancel failed"),
),
tag = "schedules"
)]
pub(super) async fn post_schedule_cancel(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,

View file

@ -11,11 +11,12 @@ use std::path::Path;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use utoipa::IntoParams;
use super::error_response;
use crate::paths::{AGENTS_ROOT, SHARED_ROOT};
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct StateFileQuery {
path: String,
}
@ -163,6 +164,20 @@ pub fn scan_validated_paths(body: &str) -> Vec<String> {
out
}
/// `GET /api/state-file?path=…` — serve an allow-listed per-agent
/// `state/` or `shared/` file. Raster images get their real
/// content-type; everything else is served as (possibly truncated)
/// text.
#[utoipa::path(
get,
path = "/api/state-file",
params(StateFileQuery),
responses(
(status = 200, description = "file contents (text, truncated at 1 MiB) or image bytes"),
(status = 500, description = "path outside the allow-list, not a regular file, or read failed"),
),
tag = "state_files"
)]
pub(super) async fn get_state_file(
axum::extract::Query(q): axum::extract::Query<StateFileQuery>,
) -> Response {

View file

@ -291,6 +291,21 @@ where
/// minutes.
const CRASH_WARNING_WINDOW: std::time::Duration = std::time::Duration::from_mins(10);
/// `GET /api/state` — cold-load snapshot of the whole dashboard: roster,
/// approvals (+ history), questions (+ history), tombstones, job queue,
/// meta inputs, and more. Live clients then follow `/api/dashboard/stream`
/// (SSE) for incremental updates keyed off `seq`.
// `StateSnapshot` is a large tree of nested view types (`ContainerView`,
// `ApprovalView`, `QuestionView`, ...) with no `ToSchema` anywhere in that
// graph; wiring it up is a schema-modelling project of its own, well past
// "annotate what's reachable". `serde_json::Value` placeholder for now —
// see the batch report.
#[utoipa::path(
get,
path = "/api/state",
responses((status = 200, description = "full dashboard snapshot", body = serde_json::Value)),
tag = "state_snapshot"
)]
pub(super) async fn api_state(
headers: HeaderMap,
State(state): State<AppState>,

View file

@ -104,6 +104,19 @@ pub(crate) async fn emit_tombstones_snapshot(coord: &Arc<Coordinator>) {
});
}
/// `POST /api/purge-tombstone/{name}` — wipe a tombstoned agent's
/// retained state dir + applied config dir entirely.
#[utoipa::path(
post,
path = "/api/purge-tombstone/{name}",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "purged", body = String),
(status = 400, description = "bad agent name"),
(status = 500, description = "a live container still exists, or a dir removal failed"),
),
tag = "tombstones"
)]
pub(super) async fn post_purge_tombstone(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,

View file

@ -16,6 +16,7 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Deserialize;
use utoipa::ToSchema;
use problem_details::ProblemDetails;
@ -31,7 +32,7 @@ use crate::job_queue::{Source, submit};
/// `--root` flag for safety; the HTTP surface is permissive
/// because the dashboard form encodes "no value" as the empty
/// string for the optional radio-group input.)
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct SetParentForm {
child: String,
new_parent: Option<String>,
@ -39,7 +40,7 @@ pub(super) struct SetParentForm {
/// One entry in a `POST /api/topology/set-parent-bulk` JSON array.
/// `new_parent`: absent/null/empty-string all mean "promote to root".
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub(super) struct SetParentBulkEntry {
child: String,
#[serde(default)]
@ -56,6 +57,15 @@ pub(super) struct SetParentBulkEntry {
/// re-emits the queue snapshot immediately so the dashboard shows the
/// queued move without a refresh; the tree itself repaints once the
/// commit lands.
#[utoipa::path(
post,
path = "/api/topology/set-parent",
responses(
(status = 200, description = "reparent queued", body = String),
(status = 400, description = "missing/invalid child or new_parent identifier"),
),
tag = "topology"
)]
pub(super) async fn post_set_parent(
State(state): State<AppState>,
Form(form): Form<SetParentForm>,
@ -98,6 +108,15 @@ pub(super) async fn post_set_parent(
/// First identifier that fails to parse aborts the whole batch before
/// anything is submitted — a partially-invalid bulk move never reaches
/// the queue.
#[utoipa::path(
post,
path = "/api/topology/set-parent-bulk",
responses(
(status = 200, description = "reparents queued", body = String),
(status = 400, description = "an invalid child identifier in the batch"),
),
tag = "topology"
)]
pub(super) async fn post_set_parent_bulk(
State(state): State<AppState>,
axum::Json(body): axum::Json<Vec<SetParentBulkEntry>>,

View file

@ -69,6 +69,24 @@ pub(super) struct PushWebhookRepo {
///
/// The gateway routes `/webhook/` → hive-c0re; the HMAC secret protects
/// the endpoint from unauthenticated callers.
#[utoipa::path(
post,
path = "/webhook/knowledge",
request_body(
content = String,
content_type = "application/json",
description = "Forgejo push-webhook payload, taken as raw bytes \
(not a typed extractor) so HMAC verification runs \
over the exact wire bytes before any JSON parsing"
),
responses(
(status = 200, description = "processed (pull triggered or ignored)", body = String),
(status = 400, description = "invalid JSON payload"),
(status = 401, description = "bad or missing HMAC signature"),
(status = 503, description = "HMAC secret unavailable at startup"),
),
tag = "webhook"
)]
pub(super) async fn post_webhook_knowledge(
State(state): State<AppState>,
headers: HeaderMap,
@ -177,6 +195,25 @@ struct PrWebhookRepo {
///
/// hive-c0re registers this hook automatically at startup via
/// [`crate::forge::ensure_config_pr_webhook`].
#[utoipa::path(
post,
path = "/webhook/config-pr",
request_body(
content = String,
content_type = "application/json",
description = "Forgejo pull_request-webhook payload, taken as raw \
bytes (not a typed extractor) so HMAC verification \
runs over the exact wire bytes before any JSON \
parsing"
),
responses(
(status = 200, description = "processed (approval queued or ignored)", body = String),
(status = 400, description = "invalid JSON payload"),
(status = 401, description = "bad or missing HMAC signature"),
(status = 503, description = "HMAC secret unavailable at startup"),
),
tag = "webhook"
)]
pub(super) async fn post_webhook_config_pr(
State(state): State<AppState>,
headers: HeaderMap,

View file

@ -26,6 +26,7 @@ use std::time::Duration;
use serde::Serialize;
use tokio::time::sleep;
use utoipa::ToSchema;
use crate::coordinator::Coordinator;
@ -36,7 +37,7 @@ const CPU_SAMPLE: Duration = Duration::from_millis(200);
const MACHINE_SLICE: &str = "/sys/fs/cgroup/machine.slice";
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct ContainerResource {
/// Agent name (without the `h-` machine prefix).
pub name: String,

View file

@ -22,6 +22,7 @@ use std::time::Duration;
use rusqlite::{Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::coordinator::Coordinator;
use hive_sh4re::wire_time::now_unix;
@ -168,13 +169,13 @@ fn resolve_prices(model: &str, table: &PriceTable) -> Prices {
}
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct KeyCount {
pub key: String,
pub count: u64,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct AgentRollup {
pub name: String,
pub turns: u64,
@ -186,7 +187,7 @@ pub struct AgentRollup {
pub est_cost_usd: f64,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct HiveStats {
pub window: &'static str,
pub from: i64,

View file

@ -11,6 +11,7 @@ use hive_sh4re::wire_time::now_unix;
use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize;
use tokio::sync::broadcast;
use utoipa::ToSchema;
/// Process-singleton handle, set once at coordinator startup. Lets
/// the `lifecycle` module's `run` / `prebuild_toplevel` access the
@ -87,7 +88,7 @@ impl BuildStatus {
/// Header-only row returned by `list_recent_for_agent`. Carries the
/// metadata the dashboard's agent-card chip needs (status + age +
/// id-to-open) without the multi-MB stdout/stderr payload.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct BuildLogHeader {
pub id: i64,
pub agent: String,
@ -103,7 +104,7 @@ pub struct BuildLogHeader {
/// Full row with stdout/stderr text inlined. Returned by `get_full`,
/// backs the side-panel viewer's payload.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct BuildLogFull {
#[serde(flatten)]
pub header: BuildLogHeader,

View file

@ -240,7 +240,7 @@ fn notify_operator_missing_target(coord: &Coordinator, schedule: &Schedule, targ
/// Per-target outcome counts for one `fire_now` invocation.
/// Returned to the operator so the dashboard can render
/// "fired to N (M failed, K missing)" without a follow-up GET.
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
pub struct FireNowReport {
/// Targets the broker accepted the message for.
pub ok: u32,