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 hive_sh4re::Approval;
use serde::Deserialize; use serde::Deserialize;
use utoipa::ToSchema;
use super::{AppState, error_response}; use super::{AppState, error_response};
use crate::actions; use crate::actions;
use crate::coordinator::Coordinator; 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( pub(super) async fn post_approve(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<i64>, 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 { pub(super) struct DenyForm {
#[serde(default)] #[serde(default)]
note: Option<String>, 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( pub(super) async fn post_deny(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<i64>, AxumPath(id): AxumPath<i64>,

View file

@ -17,10 +17,12 @@ use axum::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio_stream::Stream; use tokio_stream::Stream;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use utoipa::IntoParams;
use super::{AppState, Ident, error_response}; use super::{AppState, Ident, error_response};
use crate::build_logs::{BuildLogFull, BuildLogHeader};
#[derive(Deserialize)] #[derive(Deserialize, IntoParams)]
pub(super) struct BuildLogsAllQuery { pub(super) struct BuildLogsAllQuery {
/// Max rows to return. Capped at 100. Default 30. /// Max rows to return. Capped at 100. Default 30.
#[serde(default)] #[serde(default)]
@ -29,6 +31,16 @@ pub(super) struct BuildLogsAllQuery {
/// `GET /api/build-logs?limit=N` — most-recent build log headers across /// `GET /api/build-logs?limit=N` — most-recent build log headers across
/// all agents, newest first. Same JSON shape as the per-agent endpoint. /// 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( pub(super) async fn get_build_logs_all(
State(state): State<AppState>, State(state): State<AppState>,
axum::extract::Query(q): axum::extract::Query<BuildLogsAllQuery>, 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 { pub(super) struct BuildLogsQuery {
/// Maximum number of rows to return. Capped server-side at 50 /// Maximum number of rows to return. Capped server-side at 50
/// (see `build_logs::list_recent_for_agent`). Default 10. /// (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 /// `Vec<BuildLogHeader>` (JSON). Limit defaults to 10, server-side
/// cap at 50. Backs the per-agent log chip in the agent card and /// cap at 50. Backs the per-agent log chip in the agent card and
/// the side-panel header list. /// 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( pub(super) async fn get_build_logs_agent(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@ -79,6 +105,17 @@ pub(super) async fn get_build_logs_agent(
/// stderr concatenated) by id. Returns `BuildLogFull` (JSON), or /// stderr concatenated) by id. Returns `BuildLogFull` (JSON), or
/// HTTP 404 when the id doesn't exist (vacuum-reaped, or the /// HTTP 404 when the id doesn't exist (vacuum-reaped, or the
/// operator passed a stale id from a refresh race). /// 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( pub(super) async fn get_build_log_full(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<i64>, 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`, /// 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 /// 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. /// 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( pub(super) async fn get_build_log_for_node(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(node_id): AxumPath<u64>, 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` /// `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). /// 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( pub(super) async fn get_build_log_raw_for_node(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(node_id): AxumPath<u64>, 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 /// separator (same layout the JS side-panel renders). The
/// `Content-Disposition` header triggers a browser download with a /// `Content-Disposition` header triggers a browser download with a
/// descriptive filename so the operator can save and share the log. /// 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( pub(super) async fn get_build_log_raw(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<i64>, AxumPath(id): AxumPath<i64>,

View file

@ -22,6 +22,7 @@ use std::path::Path;
use axum::extract::{Form, Query}; use axum::extract::{Form, Query};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use super::{Ident, error_response}; use super::{Ident, error_response};
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
@ -41,18 +42,18 @@ fn read_base_url(dir: &Path, label: &str) -> Option<String> {
.map(|s| s.base_url) .map(|s| s.base_url)
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
struct ExtraForgeAccount { struct ExtraForgeAccount {
label: String, label: String,
base_url: Option<String>, base_url: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
struct ExtraForgesResponse { struct ExtraForgesResponse {
forges: Vec<ExtraForgeAccount>, forges: Vec<ExtraForgeAccount>,
} }
#[derive(Deserialize)] #[derive(Deserialize, IntoParams)]
pub(super) struct ExtraForgesQuery { pub(super) struct ExtraForgesQuery {
agent: String, agent: String,
} }
@ -62,6 +63,16 @@ pub(super) struct ExtraForgesQuery {
/// token` file in its state dir (mirrors `matrix_accounts.rs`'s filename-scan /// token` file in its state dir (mirrors `matrix_accounts.rs`'s filename-scan
/// listing). `base_url` is backfilled from the matching `forge-<label>.json` /// listing). `base_url` is backfilled from the matching `forge-<label>.json`
/// sidecar when present. Never returns a token. /// 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 { pub(super) async fn get_extra_forges(Query(q): Query<ExtraForgesQuery>) -> Response {
let agent = q.agent.trim(); let agent = q.agent.trim();
let Ok(agent) = Ident::parse(agent) else { 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 /// Form body for `POST /api/extra-forge-account` (urlencoded, the
/// dashboard's mutation convention). `action` is `"add"` (needs `base_url` + /// dashboard's mutation convention). `action` is `"add"` (needs `base_url` +
/// `token`) or `"remove"`. /// `token`) or `"remove"`.
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct ExtraForgeAccountForm { pub(super) struct ExtraForgeAccountForm {
agent: String, agent: String,
label: String, label: String,
@ -117,7 +128,7 @@ pub(super) struct ExtraForgeAccountForm {
token: Option<String>, token: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
struct ExtraForgeAccountResult { struct ExtraForgeAccountResult {
ok: bool, ok: bool,
} }
@ -127,6 +138,16 @@ struct ExtraForgeAccountResult {
/// deletes both files. Purely local — no remote account creation or /// deletes both files. Purely local — no remote account creation or
/// revocation, there is no admin access assumed on the external forge. /// revocation, there is no admin access assumed on the external forge.
/// Operator-authenticated (dashboard). Never echoes the token back. /// 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 { pub(super) async fn post_extra_forge_account(Form(f): Form<ExtraForgeAccountForm>) -> Response {
let agent = f.agent.trim(); let agent = f.agent.trim();
let label = f.label.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 /// `restart_infra`) and streams as an `AuditEntryAdded` event, so
/// operator-driven and agent-driven (`infra_admin`) infra actions show up /// operator-driven and agent-driven (`infra_admin`) infra actions show up
/// in the same AUDIT view. /// 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( pub(super) async fn post_infra_container(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath((name, action)): AxumPath<(String, String)>, AxumPath((name, action)): AxumPath<(String, String)>,

View file

@ -14,12 +14,13 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde::Deserialize; use serde::Deserialize;
use utoipa::{IntoParams, ToSchema};
/// Query params for `post_kill` / `post_restart`. `?graceful=1` routes to /// Query params for `post_kill` / `post_restart`. `?graceful=1` routes to
/// the graceful-stop/-restart orchestration (quiesce the harness, flush /// the graceful-stop/-restart orchestration (quiesce the harness, flush
/// `/state`, then container stop/restart) instead of an immediate hard /// `/state`, then container stop/restart) instead of an immediate hard
/// action. Defaults false → today's hard kill/restart. /// action. Defaults false → today's hard kill/restart.
#[derive(Deserialize)] #[derive(Deserialize, IntoParams)]
pub(super) struct GracefulParams { pub(super) struct GracefulParams {
#[serde(default)] #[serde(default)]
graceful: bool, 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::job_queue::{Source, submit};
use crate::{actions, lifecycle}; 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( pub(super) async fn post_rebuild(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@ -46,6 +59,22 @@ pub(super) async fn post_rebuild(
(StatusCode::OK, "ok").into_response() (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( pub(super) async fn post_kill(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@ -90,6 +119,22 @@ pub(super) async fn post_kill(
(StatusCode::OK, "ok").into_response() (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( pub(super) async fn post_restart(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@ -119,6 +164,18 @@ pub(super) async fn post_restart(
(StatusCode::OK, "ok").into_response() (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( pub(super) async fn post_start(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, 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 /// when the container next boots). Triggers an immediate rescan so the
/// `paused` badge flips on the dashboard without waiting for the next /// `paused` badge flips on the dashboard without waiting for the next
/// periodic sweep. /// 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( pub(super) async fn post_pause(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, 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 /// The inverse of `post_pause`. Removing a non-existent marker is a no-op
/// (idempotent). Triggers an immediate rescan so the paused badge clears. /// (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( pub(super) async fn post_resume(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, 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; /// Form fields for `post_resource_limits`. Both fields are optional strings;
/// an empty value clears the per-agent override for that field, falling back /// an empty value clears the per-agent override for that field, falling back
/// to the hive-wide default. /// to the hive-wide default.
#[derive(Deserialize, Default)] #[derive(Deserialize, Default, ToSchema)]
pub(super) struct ResourceLimitsForm { pub(super) struct ResourceLimitsForm {
#[serde(default)] #[serde(default)]
cpu_quota: String, cpu_quota: String,
@ -207,6 +288,20 @@ pub(super) struct ResourceLimitsForm {
/// limits take effect on the next container start or restart. Triggers an /// limits take effect on the next container start or restart. Triggers an
/// immediate rescan so `ContainerView.cpu_quota`/`memory_max` update on /// immediate rescan so `ContainerView.cpu_quota`/`memory_max` update on
/// the dashboard via SSE without waiting for the next periodic sweep. /// 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( pub(super) async fn post_resource_limits(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@ -257,6 +352,14 @@ pub(super) async fn post_resource_limits(
(StatusCode::OK, "ok").into_response() (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 { pub(super) async fn post_update_all(State(state): State<AppState>) -> Response {
let containers = lifecycle::list().await.unwrap_or_default(); let containers = lifecycle::list().await.unwrap_or_default();
for container in containers { 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() (StatusCode::OK, "ok").into_response()
} }
#[derive(Deserialize, Default)] #[derive(Deserialize, Default, ToSchema)]
pub(super) struct DestroyForm { pub(super) struct DestroyForm {
#[serde(default)] #[serde(default)]
purge: Option<String>, 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( pub(super) async fn post_destroy(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,

View file

@ -22,16 +22,17 @@ use std::path::Path;
use axum::extract::{Form, Query}; use axum::extract::{Form, Query};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use super::{Ident, error_response}; use super::{Ident, error_response};
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
#[derive(Deserialize)] #[derive(Deserialize, IntoParams)]
pub(super) struct MatrixAccountsQuery { pub(super) struct MatrixAccountsQuery {
agent: String, agent: String,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
struct MatrixAccount { struct MatrixAccount {
name: String, name: String,
/// Effective homeserver, backfilled from the daemon snapshot; `None` when /// Effective homeserver, backfilled from the daemon snapshot; `None` when
@ -46,7 +47,7 @@ struct MatrixAccount {
user_id: Option<String>, user_id: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
struct MatrixAccountsResponse { struct MatrixAccountsResponse {
accounts: Vec<MatrixAccount>, accounts: Vec<MatrixAccount>,
/// Unix mtime of the daemon's `matrix-accounts.json` snapshot (when the /// 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()) 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 { pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) -> Response {
let agent = q.agent.trim(); let agent = q.agent.trim();
// Validate through the single `Ident` type so a crafted `agent` can't // 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` + /// mutation convention). `mode` is `"password"` (needs `user_id` +
/// `password`) or `"token"` (needs `token`; `user_id` is recovered via /// `password`) or `"token"` (needs `token`; `user_id` is recovered via
/// whoami). /// whoami).
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct MatrixLoginForm { pub(super) struct MatrixLoginForm {
agent: String, agent: String,
account: String, account: String,
@ -167,7 +181,7 @@ pub(super) struct MatrixLoginForm {
token: Option<String>, token: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
struct MatrixLoginResult { struct MatrixLoginResult {
ok: bool, ok: bool,
user_id: String, user_id: String,
@ -178,6 +192,16 @@ struct MatrixLoginResult {
/// On success writes the token to `matrix-token-<account>` via hive-priv and /// On success writes the token to `matrix-token-<account>` via hive-priv and
/// kicks the daemon. Operator-authenticated (dashboard). Never echoes the /// kicks the daemon. Operator-authenticated (dashboard). Never echoes the
/// token back — only `{ ok, user_id }`. /// 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 { pub(super) async fn post_matrix_account_login(Form(f): Form<MatrixLoginForm>) -> Response {
let agent = f.agent.trim(); let agent = f.agent.trim();
let account = f.account.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 /// `github-token` file. The GitHub counterpart of the matrix login form, but
/// far simpler: no account creation, no homeserver, no login modes — the /// far simpler: no account creation, no homeserver, no login modes — the
/// operator pastes a PAT for an existing account. /// operator pastes a PAT for an existing account.
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct GithubAccountForm { pub(super) struct GithubAccountForm {
agent: String, agent: String,
token: String, token: String,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
struct GithubAccountResult { struct GithubAccountResult {
ok: bool, ok: bool,
} }
@ -275,6 +299,16 @@ struct GithubAccountResult {
/// helper read the file live, so the new token takes effect immediately. /// helper read the file live, so the new token takes effect immediately.
/// Operator-authenticated (dashboard). Never echoes the token back — only /// Operator-authenticated (dashboard). Never echoes the token back — only
/// `{ ok: true }`. /// `{ 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 { pub(super) async fn post_github_account(Form(f): Form<GithubAccountForm>) -> Response {
let agent = f.agent.trim(); let agent = f.agent.trim();
let token = f.token.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() axum::Json(GithubAccountResult { ok: true }).into_response()
} }
#[derive(Deserialize)] #[derive(Deserialize, IntoParams)]
pub(super) struct GithubAccountQuery { pub(super) struct GithubAccountQuery {
agent: String, agent: String,
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
struct GithubAccountStatus { struct GithubAccountStatus {
/// A `github-token` file exists in the agent's state dir (a PAT has been /// 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 /// 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 /// PAT provisioned (its `github-token` file exists). Lets the credentials tab
/// show "token stored" vs "not set" instead of a black-hole paste field. /// show "token stored" vs "not set" instead of a black-hole paste field.
/// Never returns the token itself. /// 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 { pub(super) async fn get_github_account(Query(q): Query<GithubAccountQuery>) -> Response {
let agent = q.agent.trim(); let agent = q.agent.trim();
let Ok(agent) = Ident::parse(agent) else { let Ok(agent) = Ident::parse(agent) else {

View file

@ -9,6 +9,7 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::coordinator::Coordinator; 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 /// list under the `inputs` field — the JS submitter joins the
/// checked boxes since axum's `Form` extractor doesn't natively /// checked boxes since axum's `Form` extractor doesn't natively
/// decode repeated keys without a helper. /// decode repeated keys without a helper.
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct MetaUpdateForm { pub(super) struct MetaUpdateForm {
inputs: String, inputs: String,
} }
@ -183,6 +184,16 @@ pub(super) struct MetaUpdateForm {
/// no rebuild ripple). Returns immediately after queueing the work; /// no rebuild ripple). Returns immediately after queueing the work;
/// dashboard polls for progress via container `pending` spinners + /// dashboard polls for progress via container `pending` spinners +
/// the meta-inputs row sha update. /// 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( pub(super) async fn post_meta_update(
State(state): State<AppState>, State(state): State<AppState>,
Form(form): Form<MetaUpdateForm>, Form(form): Form<MetaUpdateForm>,

View file

@ -9,8 +9,11 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde::Deserialize; use serde::Deserialize;
use utoipa::{IntoParams, ToSchema};
use super::{AppState, Ident, error_response, scan_validated_paths}; 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. /// Unread operator-directed messages for the dashboard's Y3R C4LL inbox.
/// Returns messages addressed to `"operator"` that haven't been /// 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 /// tokens are validated so the client renders file links like the
/// terminal does. Shape: `{ "messages": [{ id, from, body, at, /// terminal does. Shape: `{ "messages": [{ id, from, body, at,
/// in_reply_to, file_refs }] }`. /// 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 { pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Response {
const INBOX_LIMIT: u64 = 100; const INBOX_LIMIT: u64 = 100;
match state 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 { pub(super) struct StatsHiveQuery {
window: Option<String>, window: Option<String>,
} }
@ -66,6 +78,13 @@ pub(super) struct StatsHiveQuery {
/// Hive-wide turn-stats rollup for the dashboard swarm-stats view. /// Hive-wide turn-stats rollup for the dashboard swarm-stats view.
/// Aggregates every agent's `hyperhive-turn-stats.sqlite` read-only /// Aggregates every agent's `hyperhive-turn-stats.sqlite` read-only
/// (skips missing/unreadable ones). Window defaults to `24h`. /// (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( pub(super) async fn api_stats_hive(
State(state): State<AppState>, State(state): State<AppState>,
axum::extract::Query(q): axum::extract::Query<StatsHiveQuery>, 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 /// Live per-agent-container CPU + memory load from cgroup v2. Samples
/// CPU over a short interval (~200 ms), so this call briefly awaits. /// 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 { pub(super) async fn api_container_resources() -> Response {
axum::Json(crate::container_stats::gather().await).into_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 /// `{ "entries": [AuditEntry…], "total": N }` so the UI can show
/// "latest 500 of N" rather than silently capping. `ts_unix` is in /// "latest 500 of N" rather than silently capping. `ts_unix` is in
/// **seconds**. /// **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 { pub(super) async fn api_audit_log(State(state): State<AppState>) -> Response {
const LIMIT: usize = 500; const LIMIT: usize = 500;
let entries = match state.coord.audit_log.list_recent(LIMIT) { 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 /// rows so vacuum can collect them). Returns `{ "marked": N }` so the
/// frontend can show "cleared N messages" feedback without an extra /// frontend can show "cleared N messages" feedback without an extra
/// fetch. /// 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( pub(super) async fn post_mark_all_read(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, 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 /// validation that `to` resolves to a known agent — broker accepts
/// arbitrary recipients (and the agent's inbox grows whether or not /// arbitrary recipients (and the agent's inbox grows whether or not
/// they exist, which is fine for spawn-then-greet flows). /// they exist, which is fine for spawn-then-greet flows).
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct OpSendForm { pub(super) struct OpSendForm {
to: String, to: String,
body: 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( pub(super) async fn post_op_send(
State(state): State<AppState>, State(state): State<AppState>,
Form(form): Form<OpSendForm>, Form(form): Form<OpSendForm>,
@ -180,11 +237,22 @@ pub(super) async fn post_op_send(
(axum::http::StatusCode::OK, "ok").into_response() (axum::http::StatusCode::OK, "ok").into_response()
} }
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct RequestSpawnForm { pub(super) struct RequestSpawnForm {
name: String, 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( pub(super) async fn post_request_spawn(
State(state): State<AppState>, State(state): State<AppState>,
Form(form): Form<RequestSpawnForm>, Form(form): Form<RequestSpawnForm>,

View file

@ -37,6 +37,22 @@ use crate::lifecycle;
tags( tags(
(name = "health", description = "hive-wide liveness/readiness probes"), (name = "health", description = "hive-wide liveness/readiness probes"),
(name = "journal", description = "container + host journal reads"), (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; struct ApiDoc;
@ -423,3 +439,18 @@ mod tests {
assert_eq!(fv["status"], 500); 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}, response::{IntoResponse, Response},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use problem_details::ProblemDetails; use problem_details::ProblemDetails;
use super::{AppState, Ident, guard_agent_name, strip_container_prefix}; use super::{AppState, Ident, guard_agent_name, strip_container_prefix};
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub(super) struct ToolGroupsSnapshot { pub(super) struct ToolGroupsSnapshot {
/// Ordered list of all known tool-group names. Drives the column /// Ordered list of all known tool-group names. Drives the column
/// headers in the capabilities table — the UI does not hard-code them. /// 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>>, 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( pub(super) async fn get_tool_groups(
State(state): State<AppState>, State(state): State<AppState>,
) -> axum::Json<ToolGroupsSnapshot> { ) -> axum::Json<ToolGroupsSnapshot> {
@ -105,11 +114,25 @@ pub(crate) fn roster_and_effective(
(agents, effective) (agents, effective)
} }
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct SetToolGroupsBody { pub(super) struct SetToolGroupsBody {
groups: Vec<String>, 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( pub(super) async fn post_tool_groups(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@ -145,7 +168,7 @@ pub(super) async fn post_tool_groups(
Ok((StatusCode::OK, "ok").into_response()) Ok((StatusCode::OK, "ok").into_response())
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub(super) struct CapabilitiesSnapshot { pub(super) struct CapabilitiesSnapshot {
/// Ordered list of all known capability names. Drives the column /// Ordered list of all known capability names. Drives the column
/// headers in the capabilities table — the UI does not hard-code them. /// 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>>, 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( pub(super) async fn get_capabilities(
State(state): State<AppState>, State(state): State<AppState>,
) -> axum::Json<CapabilitiesSnapshot> { ) -> axum::Json<CapabilitiesSnapshot> {
@ -191,11 +222,25 @@ pub(super) async fn get_capabilities(
}) })
} }
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct SetCapabilitiesBody { pub(super) struct SetCapabilitiesBody {
caps: Vec<String>, 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( pub(super) async fn post_capabilities(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, 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 /// field leaves that perm-type untouched, an empty array clears it, a
/// populated array fully replaces it (same replace semantics as the /// populated array fully replaces it (same replace semantics as the
/// per-agent endpoints). /// per-agent endpoints).
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct PermChangeBody { pub(super) struct PermChangeBody {
agent: String, agent: String,
#[serde(default)] #[serde(default)]
@ -245,7 +290,7 @@ pub(super) struct PermChangeBody {
capabilities: Option<Vec<String>>, capabilities: Option<Vec<String>>,
} }
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct BatchPermsBody { pub(super) struct BatchPermsBody {
changes: Vec<PermChangeBody>, 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 /// groups both changed rebuilds once, not twice. The whole batch is
/// atomic: every change is validated up front and on any validation /// atomic: every change is validated up front and on any validation
/// error nothing is written or enqueued. /// 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( pub(super) async fn post_permissions(
State(state): State<AppState>, State(state): State<AppState>,
axum::Json(body): axum::Json<BatchPermsBody>, 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" /// persisted). The client uses this to drive the "stale permission entries"
/// sub-section in K3PT ST4T3 without having to fetch three separate /// sub-section in K3PT ST4T3 without having to fetch three separate
/// endpoints and perform set arithmetic on the client side. /// endpoints and perform set arithmetic on the client side.
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub(super) struct StalePermsResponse { pub(super) struct StalePermsResponse {
/// Ghost agent names, sorted. Empty list → no stale entries. /// Ghost agent names, sorted. Empty list → no stale entries.
stale: Vec<String>, 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( pub(super) async fn get_stale_permissions(
State(state): State<AppState>, State(state): State<AppState>,
) -> axum::Json<StalePermsResponse> { ) -> axum::Json<StalePermsResponse> {
@ -375,6 +439,17 @@ pub(super) async fn get_stale_permissions(
/// the format check ([`Ident::parse`]) is applied. No rebuild is /// the format check ([`Ident::parse`]) is applied. No rebuild is
/// enqueued (the agent doesn't exist to rebuild); the SSE snapshots /// enqueued (the agent doesn't exist to rebuild); the SSE snapshots
/// update the P3RM1SS10NS tab live. /// 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( pub(super) async fn delete_agent_permissions(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,

View file

@ -12,12 +12,13 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde::Deserialize; use serde::Deserialize;
use utoipa::ToSchema;
use problem_details::ProblemDetails; use problem_details::ProblemDetails;
use super::{AppState, error_response}; use super::{AppState, error_response};
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct AnswerForm { pub(super) struct AnswerForm {
answer: String, answer: String,
} }
@ -37,6 +38,20 @@ fn with_cors(resp: impl IntoResponse) -> Response {
resp 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( pub(super) async fn post_answer_question(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<i64>, 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 /// 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 /// a real answer — just lets the operator close the loop instead of
/// letting the question dangle forever. /// 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( pub(super) async fn post_cancel_question(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<i64>, AxumPath(id): AxumPath<i64>,

View file

@ -14,12 +14,25 @@ use axum::{
use problem_details::ProblemDetails; use problem_details::ProblemDetails;
use crate::scheduled_prompts::ScheduleNotFoundOrCancelled; use crate::scheduled_prompts::ScheduleNotFoundOrCancelled;
use crate::scheduled_prompts_worker::FireNowReport;
use super::{AppState, error_problem, error_response}; use super::{AppState, error_problem, error_response};
/// `GET /api/schedules` — snapshot of every schedule for the /// `GET /api/schedules` — snapshot of every schedule for the
/// scheduled-prompts tab. Returns the wire shape directly /// scheduled-prompts tab. Returns the wire shape directly
/// so the frontend can render without an extra translation layer. /// 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 { pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
match state.coord.scheduled_prompts.list() { match state.coord.scheduled_prompts.list() {
Ok(rows) => { 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 /// skips the approval gate — the operator click *is* the
/// approval. The schedule lands directly with /// approval. The schedule lands directly with
/// `source = Operator` and the worker picks it up at fire time. /// `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( pub(super) async fn post_schedule_new(
State(state): State<AppState>, State(state): State<AppState>,
axum::Json(payload): axum::Json<hive_sh4re::SchedulePromptPayload>, 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 ⇒ /// Optional JSON body for `fire-now`. Absent / empty body ⇒
/// `reset_timer = false` (back-compat: cadence stays intact). /// `reset_timer = false` (back-compat: cadence stays intact).
#[derive(serde::Deserialize, Default)] #[derive(serde::Deserialize, Default, utoipa::ToSchema)]
pub(super) struct FireNowBody { pub(super) struct FireNowBody {
#[serde(default)] #[serde(default)]
reset_timer: bool, reset_timer: bool,
@ -100,6 +127,17 @@ pub(super) struct FireNowBody {
/// wrong." For recurring schedules the cadence stays intact unless /// wrong." For recurring schedules the cadence stays intact unless
/// the body carries `{"reset_timer": true}`, in which case the /// the body carries `{"reset_timer": true}`, in which case the
/// countdown is re-armed from now (`next_fire_at = now + interval`). /// 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( pub(super) async fn post_schedule_fire_now(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<i64>, 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 /// flip to Cancelled, `{"cancelled": false}` when the DAG was
/// Running / terminal / gone. On success a fresh `RebuildQueueChanged` /// Running / terminal / gone. On success a fresh `RebuildQueueChanged`
/// snapshot fires so the state flip surfaces live. /// 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( pub(super) async fn post_rebuild_queue_cancel(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<u64>, 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 { pub(super) struct CancelScheduleForm {
/// `None` / absent / empty array → cancel whole schedule. /// `None` / absent / empty array → cancel whole schedule.
#[serde(default)] #[serde(default)]
targets: Option<Vec<String>>, targets: Option<Vec<String>>,
} }
#[derive(serde::Deserialize, Default)] #[derive(serde::Deserialize, Default, utoipa::ToSchema)]
#[allow( #[allow(
clippy::option_option, clippy::option_option,
reason = "double-Option carries three-state PATCH semantics on the wire \ reason = "double-Option carries three-state PATCH semantics on the wire \
@ -203,6 +248,16 @@ where
/// `interval_seconds`. Cancelled schedules are refused — submit /// `interval_seconds`. Cancelled schedules are refused — submit
/// a new one instead. Returns the updated `WireSchedule` so the /// a new one instead. Returns the updated `WireSchedule` so the
/// caller's post-edit refresh has the new state inline. /// 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( pub(super) async fn patch_schedule(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<i64>, 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 /// `POST /api/schedules/{id}/pause` — pause a schedule so the worker
/// skips it until explicitly resumed. Idempotent; no-op on an already- /// skips it until explicitly resumed. Idempotent; no-op on an already-
/// paused row. Returns 404 when the schedule is cancelled or not found. /// 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( pub(super) async fn post_schedule_pause(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<i64>, AxumPath(id): AxumPath<i64>,
@ -255,6 +321,17 @@ pub(super) async fn post_schedule_pause(
/// `POST /api/schedules/{id}/resume` — resume a paused schedule. /// `POST /api/schedules/{id}/resume` — resume a paused schedule.
/// Idempotent; no-op on an already-active row. Returns 404 when the /// Idempotent; no-op on an already-active row. Returns 404 when the
/// schedule is cancelled or not found. /// 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( pub(super) async fn post_schedule_resume(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<i64>, 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 /// (whole schedule when no `targets` field, partial when one is
/// provided). Operator bypasses the topology check; the manager /// provided). Operator bypasses the topology check; the manager
/// surface enforces it for agent callers. /// 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( pub(super) async fn post_schedule_cancel(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<i64>, AxumPath(id): AxumPath<i64>,

View file

@ -11,11 +11,12 @@ use std::path::Path;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use serde::Deserialize; use serde::Deserialize;
use utoipa::IntoParams;
use super::error_response; use super::error_response;
use crate::paths::{AGENTS_ROOT, SHARED_ROOT}; use crate::paths::{AGENTS_ROOT, SHARED_ROOT};
#[derive(Deserialize)] #[derive(Deserialize, IntoParams)]
pub(super) struct StateFileQuery { pub(super) struct StateFileQuery {
path: String, path: String,
} }
@ -163,6 +164,20 @@ pub fn scan_validated_paths(body: &str) -> Vec<String> {
out 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( pub(super) async fn get_state_file(
axum::extract::Query(q): axum::extract::Query<StateFileQuery>, axum::extract::Query(q): axum::extract::Query<StateFileQuery>,
) -> Response { ) -> Response {

View file

@ -291,6 +291,21 @@ where
/// minutes. /// minutes.
const CRASH_WARNING_WINDOW: std::time::Duration = std::time::Duration::from_mins(10); 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( pub(super) async fn api_state(
headers: HeaderMap, headers: HeaderMap,
State(state): State<AppState>, 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( pub(super) async fn post_purge_tombstone(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,

View file

@ -16,6 +16,7 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde::Deserialize; use serde::Deserialize;
use utoipa::ToSchema;
use problem_details::ProblemDetails; use problem_details::ProblemDetails;
@ -31,7 +32,7 @@ use crate::job_queue::{Source, submit};
/// `--root` flag for safety; the HTTP surface is permissive /// `--root` flag for safety; the HTTP surface is permissive
/// because the dashboard form encodes "no value" as the empty /// because the dashboard form encodes "no value" as the empty
/// string for the optional radio-group input.) /// string for the optional radio-group input.)
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct SetParentForm { pub(super) struct SetParentForm {
child: String, child: String,
new_parent: Option<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. /// One entry in a `POST /api/topology/set-parent-bulk` JSON array.
/// `new_parent`: absent/null/empty-string all mean "promote to root". /// `new_parent`: absent/null/empty-string all mean "promote to root".
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub(super) struct SetParentBulkEntry { pub(super) struct SetParentBulkEntry {
child: String, child: String,
#[serde(default)] #[serde(default)]
@ -56,6 +57,15 @@ pub(super) struct SetParentBulkEntry {
/// re-emits the queue snapshot immediately so the dashboard shows the /// re-emits the queue snapshot immediately so the dashboard shows the
/// queued move without a refresh; the tree itself repaints once the /// queued move without a refresh; the tree itself repaints once the
/// commit lands. /// 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( pub(super) async fn post_set_parent(
State(state): State<AppState>, State(state): State<AppState>,
Form(form): Form<SetParentForm>, 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 /// First identifier that fails to parse aborts the whole batch before
/// anything is submitted — a partially-invalid bulk move never reaches /// anything is submitted — a partially-invalid bulk move never reaches
/// the queue. /// 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( pub(super) async fn post_set_parent_bulk(
State(state): State<AppState>, State(state): State<AppState>,
axum::Json(body): axum::Json<Vec<SetParentBulkEntry>>, 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 gateway routes `/webhook/` → hive-c0re; the HMAC secret protects
/// the endpoint from unauthenticated callers. /// 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( pub(super) async fn post_webhook_knowledge(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@ -177,6 +195,25 @@ struct PrWebhookRepo {
/// ///
/// hive-c0re registers this hook automatically at startup via /// hive-c0re registers this hook automatically at startup via
/// [`crate::forge::ensure_config_pr_webhook`]. /// [`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( pub(super) async fn post_webhook_config_pr(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,

View file

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

View file

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

View file

@ -11,6 +11,7 @@ use hive_sh4re::wire_time::now_unix;
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize; use serde::Serialize;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use utoipa::ToSchema;
/// Process-singleton handle, set once at coordinator startup. Lets /// Process-singleton handle, set once at coordinator startup. Lets
/// the `lifecycle` module's `run` / `prebuild_toplevel` access the /// 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 /// Header-only row returned by `list_recent_for_agent`. Carries the
/// metadata the dashboard's agent-card chip needs (status + age + /// metadata the dashboard's agent-card chip needs (status + age +
/// id-to-open) without the multi-MB stdout/stderr payload. /// id-to-open) without the multi-MB stdout/stderr payload.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct BuildLogHeader { pub struct BuildLogHeader {
pub id: i64, pub id: i64,
pub agent: String, pub agent: String,
@ -103,7 +104,7 @@ pub struct BuildLogHeader {
/// Full row with stdout/stderr text inlined. Returned by `get_full`, /// Full row with stdout/stderr text inlined. Returned by `get_full`,
/// backs the side-panel viewer's payload. /// backs the side-panel viewer's payload.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct BuildLogFull { pub struct BuildLogFull {
#[serde(flatten)] #[serde(flatten)]
pub header: BuildLogHeader, 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. /// Per-target outcome counts for one `fire_now` invocation.
/// Returned to the operator so the dashboard can render /// Returned to the operator so the dashboard can render
/// "fired to N (M failed, K missing)" without a follow-up GET. /// "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 { pub struct FireNowReport {
/// Targets the broker accepted the message for. /// Targets the broker accepted the message for.
pub ok: u32, pub ok: u32,