Watch
0
0
Fork
You've already forked hyperhive
0

Set resource caps and inference when creating an agent

POST /api/agents takes optional limits and inference objects, checked by
the same rules as the two PUT routes before anything is queued and stored
ahead of the job graph. The create form gets collapsed sections for both,
reusing the fields of the detail-panel editors.
This commit is contained in:
iris 2026-10-15 11:32:48 +02:00 • committed by mara
commit e36a15de91
8 changed files with 389 additions and 100 deletions

View file

@ -296,6 +296,18 @@ row shows the new value before the agent runs under it. The panel reads
`GET /api/agents/{agent}/limits`. [hivectl](../tools/hivectl.md#per-agent-resource-limits) `GET /api/agents/{agent}/limits`. [hivectl](../tools/hivectl.md#per-agent-resource-limits)
sets the hive's own limits. sets the hive's own limits.
### Settings at creation
The **+ agent** dialog has two collapsed sections, **resource caps** and
**inference**, with the same fields as the detail panel's editors. Leave them
closed or empty and the request is the plain name and hive. Fill them and
`POST /api/agents` carries them as optional `limits` and `inference` objects,
the same shapes the two `PUT` routes take. The controller checks them with the
`PUT` rules before it queues anything, so a bad value gets a refusal with its
reason and creates no agent. It stores them ahead of the job graph, so they
are in place before the agent first runs. Creating a name that already exists with no settings
leaves any stored ones alone.
### Linking external accounts ### Linking external accounts
Each agent on `/agents` opens three dialogs that write a credential for it Each agent on `/agents` opens three dialogs that write a credential for it

View file

@ -88,6 +88,100 @@ function inferenceUrl(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/inference`; return `/api/agents/${encodeURIComponent(agent)}/inference`;
} }
// The four inference boxes as typed. Shared by the editor here and the
// create-agent form, which send what `draftBody` makes of them.
export interface InferenceDraft {
model: string;
effort: string;
subagents: string;
maxSubagents: string;
}
export const EMPTY_INFERENCE_DRAFT: InferenceDraft = {
model: "",
effort: "",
subagents: "",
maxSubagents: "",
};
// Why the draft would be refused, or null when it is fine to send.
export function draftProblem(d: InferenceDraft): string | null {
return (
modelNameProblem(d.model.trim()) ??
parseModels(d.subagents)
.map(modelNameProblem)
.find((p) => p !== null) ??
(maxSubagentsValue(d.maxSubagents) === null
? `max subagents is a whole number from 1 to ${MAX_SUBAGENTS}, or empty for no cap`
: null)
);
}
export function draftIsEmpty(d: InferenceDraft): boolean {
return (
d.model.trim() === "" &&
d.effort === "" &&
parseModels(d.subagents).length === 0 &&
d.maxSubagents.trim() === ""
);
}
// Only the filled fields: an empty one leaves the agent on its own value.
export function draftBody(d: InferenceDraft): Settings {
const body: Settings = {};
if (d.model.trim() !== "") body.model = d.model.trim();
if (d.effort !== "") body.effort = d.effort;
const models = parseModels(d.subagents);
if (models.length > 0) body.subagent_models = models;
const max = maxSubagentsValue(d.maxSubagents);
if (typeof max === "number") body.max_subagents = max;
return body;
}
export function InferenceFields({
draft,
onChange,
}: {
draft: InferenceDraft;
onChange: (d: InferenceDraft) => void;
}) {
const problem = draftProblem(draft);
return (
<>
<TextField
id="inference-model"
label="model"
value={draft.model}
placeholder="e.g. opus"
onInput={(model) => onChange({ ...draft, model })}
/>
<SelectField
id="inference-effort"
label="effort"
value={draft.effort}
options={EFFORT_OPTIONS}
onChange={(effort) => onChange({ ...draft, effort })}
/>
<TextField
id="inference-subagent-models"
label="subagent models"
value={draft.subagents}
placeholder="e.g. haiku, sonnet"
onInput={(subagents) => onChange({ ...draft, subagents })}
/>
<TextField
id="inference-max-subagents"
label="max subagents"
value={draft.maxSubagents}
placeholder="empty for no cap"
pattern="[0-9]*"
onInput={(maxSubagents) => onChange({ ...draft, maxSubagents })}
/>
{problem ? <p class="ui-agent-inference-problem">{problem}</p> : null}
</>
);
}
export function AgentInference({ agent }: { agent: string }) { export function AgentInference({ agent }: { agent: string }) {
const [view, setView] = useState<View | null>(null); const [view, setView] = useState<View | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
@ -219,29 +313,17 @@ function InferenceForm({
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
}) { }) {
const [model, setModel] = useState(configured?.model ?? ""); const [draft, setDraft] = useState<InferenceDraft>({
const [effort, setEffort] = useState(configured?.effort ?? ""); model: configured?.model ?? "",
const [subagents, setSubagents] = useState( effort: configured?.effort ?? "",
(configured?.subagent_models ?? []).join(", "), subagents: (configured?.subagent_models ?? []).join(", "),
); maxSubagents: configured?.max_subagents?.toString() ?? "",
const [maxSubagents, setMaxSubagents] = useState( });
configured?.max_subagents?.toString() ?? "",
);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<ProblemDetails | null>(null); const [problem, setProblem] = useState<ProblemDetails | null>(null);
const subagentList = parseModels(subagents); const formProblem = draftProblem(draft);
const formProblem = const empty = draftIsEmpty(draft);
modelNameProblem(model.trim()) ??
subagentList.map(modelNameProblem).find((p) => p !== null) ??
(maxSubagentsValue(maxSubagents) === null
? `max subagents is a whole number from 1 to ${MAX_SUBAGENTS}, or empty for no cap`
: null);
const empty =
model.trim() === "" &&
effort === "" &&
subagentList.length === 0 &&
maxSubagents.trim() === "";
async function send(init: RequestInit) { async function send(init: RequestInit) {
setBusy(true); setBusy(true);
@ -261,16 +343,10 @@ function InferenceForm({
function save(e: Event) { function save(e: Event) {
e.preventDefault(); e.preventDefault();
const body: Settings = {};
if (model.trim() !== "") body.model = model.trim();
if (effort !== "") body.effort = effort;
if (subagentList.length > 0) body.subagent_models = subagentList;
const max = maxSubagentsValue(maxSubagents);
if (typeof max === "number") body.max_subagents = max;
void send({ void send({
method: "PUT", method: "PUT",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(draftBody(draft)),
}); });
} }
@ -284,38 +360,7 @@ function InferenceForm({
running. running.
</p> </p>
<form class="ui-agent-inference-form" onSubmit={save}> <form class="ui-agent-inference-form" onSubmit={save}>
<TextField <InferenceFields draft={draft} onChange={setDraft} />
id="inference-model"
label="model"
value={model}
placeholder="e.g. opus"
onInput={setModel}
/>
<SelectField
id="inference-effort"
label="effort"
value={effort}
options={EFFORT_OPTIONS}
onChange={setEffort}
/>
<TextField
id="inference-subagent-models"
label="subagent models"
value={subagents}
placeholder="e.g. haiku, sonnet"
onInput={setSubagents}
/>
<TextField
id="inference-max-subagents"
label="max subagents"
value={maxSubagents}
placeholder="empty for no cap"
pattern="[0-9]*"
onInput={setMaxSubagents}
/>
{formProblem ? (
<p class="ui-agent-inference-problem">{formProblem}</p>
) : null}
{problem ? ( {problem ? (
<ApiErrorPanel context="saving failed" problem={problem} /> <ApiErrorPanel context="saving failed" problem={problem} />
) : null} ) : null}

View file

@ -34,6 +34,55 @@ function limitsUrl(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/limits`; return `/api/agents/${encodeURIComponent(agent)}/limits`;
} }
// The caps as typed, as the body the route takes: only the filled fields, so
// an empty box leaves that field to the hive.
export function capsBody(cpu: string, memory: string): Caps {
const body: Caps = {};
if (cpu.trim() !== "") body.cpu_quota = cpu.trim();
if (memory.trim() !== "") body.memory_max = memory.trim();
return body;
}
// The two cap boxes with their hints, shared by the editor here and the
// create-agent form.
export function LimitsFields({
cpu,
memory,
onCpu,
onMemory,
}: {
cpu: string;
memory: string;
onCpu: (v: string) => void;
onMemory: (v: string) => void;
}) {
return (
<>
<TextField
id="limits-cpu-quota"
label="cpu quota"
value={cpu}
placeholder="e.g. 400%"
onInput={onCpu}
/>
<p class="ui-agent-limits-hint">
A percentage of one core: 100% is one core, 400% is four.
</p>
<TextField
id="limits-memory-max"
label="memory max"
value={memory}
placeholder="e.g. 8G"
onInput={onMemory}
/>
<p class="ui-agent-limits-hint">
A size such as 512M or 8G, a percentage of the machine's memory such as
50%, or infinity.
</p>
</>
);
}
export function AgentLimits({ agent }: { agent: string }) { export function AgentLimits({ agent }: { agent: string }) {
const [caps, setCaps] = useState<Caps | null>(null); const [caps, setCaps] = useState<Caps | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
@ -152,13 +201,10 @@ function LimitsForm({
e.preventDefault(); e.preventDefault();
// Only the filled fields: a PUT replaces the stored value whole, so an // Only the filled fields: a PUT replaces the stored value whole, so an
// empty box is cleared and the hive decides that field again. // empty box is cleared and the hive decides that field again.
const body: Caps = {};
if (cpu.trim() !== "") body.cpu_quota = cpu.trim();
if (memory.trim() !== "") body.memory_max = memory.trim();
void send({ void send({
method: "PUT", method: "PUT",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(capsBody(cpu, memory)),
}); });
} }
@ -171,27 +217,12 @@ function LimitsForm({
agent takes it at its next spawn or rebuild. agent takes it at its next spawn or rebuild.
</p> </p>
<form class="ui-agent-limits-form" onSubmit={save}> <form class="ui-agent-limits-form" onSubmit={save}>
<TextField <LimitsFields
id="limits-cpu-quota" cpu={cpu}
label="cpu quota" memory={memory}
value={cpu} onCpu={setCpu}
placeholder="e.g. 400%" onMemory={setMemory}
onInput={setCpu}
/> />
<p class="ui-agent-limits-hint">
A percentage of one core: 100% is one core, 400% is four.
</p>
<TextField
id="limits-memory-max"
label="memory max"
value={memory}
placeholder="e.g. 8G"
onInput={setMemory}
/>
<p class="ui-agent-limits-hint">
A size such as 512M or 8G, a percentage of the machine's memory such
as 50%, or infinity.
</p>
{problem ? ( {problem ? (
<ApiErrorPanel context="the caps were not saved" problem={problem} /> <ApiErrorPanel context="the caps were not saved" problem={problem} />
) : null} ) : null}

View file

@ -48,6 +48,21 @@
align-items: flex-start; align-items: flex-start;
gap: 0.75em; gap: 0.75em;
} }
/* Optional settings, collapsed so the plain name-and-hive form stays short. */
.create-agent-section {
align-self: stretch;
}
.create-agent-section > summary {
cursor: pointer;
color: var(--muted);
}
.create-agent-section-body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
margin-top: 0.75em;
}
.create-agent-result { .create-agent-result {
margin-top: 1em; margin-top: 1em;
} }

View file

@ -36,6 +36,14 @@ import {
type SelectOption, type SelectOption,
} from "../ui/select-field/SelectField.js"; } from "../ui/select-field/SelectField.js";
import { Button } from "../ui/button/Button.js"; import { Button } from "../ui/button/Button.js";
import { capsBody, LimitsFields } from "../agent/detail/AgentLimits.js";
import {
draftBody,
draftProblem,
EMPTY_INFERENCE_DRAFT,
InferenceFields,
type InferenceDraft,
} from "../agent/detail/AgentInference.js";
import "./CreateAgentForm.css"; import "./CreateAgentForm.css";
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage` // Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
@ -88,6 +96,13 @@ export function CreateAgentForm({
const [hives, setHives] = useState<HiveEntry[] | null>(null); const [hives, setHives] = useState<HiveEntry[] | null>(null);
const [hivesError, setHivesError] = useState<ProblemDetails | null>(null); const [hivesError, setHivesError] = useState<ProblemDetails | null>(null);
const [result, setResult] = useState<SubmitState>({ status: "idle" }); const [result, setResult] = useState<SubmitState>({ status: "idle" });
// The optional settings sections, left empty unless the operator fills them.
const [cpu, setCpu] = useState("");
const [memory, setMemory] = useState("");
const [inference, setInference] = useState<InferenceDraft>(
EMPTY_INFERENCE_DRAFT,
);
const inferenceProblem = draftProblem(inference);
// Loaded once, not re-fetched on submit: the roster changes rarely // Loaded once, not re-fetched on submit: the roster changes rarely
// enough (a nix-level swarm config change) that staleness within one // enough (a nix-level swarm config change) that staleness within one
@ -119,10 +134,19 @@ export function CreateAgentForm({
e.preventDefault(); e.preventDefault();
setResult({ status: "submitting" }); setResult({ status: "submitting" });
try { try {
// A section left empty is left out, so the request is the plain
// name-and-hive one unless the operator set something.
const limits = capsBody(cpu, memory);
const settings = draftBody(inference);
const r = await fetch("/api/agents", { const r = await fetch("/api/agents", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ name, hive }), body: JSON.stringify({
name,
hive,
...(Object.keys(limits).length > 0 ? { limits } : {}),
...(Object.keys(settings).length > 0 ? { inference: settings } : {}),
}),
}); });
if (!r.ok) { if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) }); setResult({ status: "error", problem: await readApiError(r) });
@ -142,7 +166,9 @@ export function CreateAgentForm({
<Panel title="create agent" icon="🤖" onClose={onClose}> <Panel title="create agent" icon="🤖" onClose={onClose}>
<p class="create-agent-intro"> <p class="create-agent-intro">
Create a new agent's swarm-level identity. This only queues the job Create a new agent's swarm-level identity. This only queues the job
— check <Link href="/jobs">jobs</Link> to watch it settle. — check <Link href="/jobs">jobs</Link> to watch it settle. Resource
caps and inference are optional; leave them closed to keep the
defaults.
</p> </p>
{hivesError && ( {hivesError && (
<ApiErrorPanel <ApiErrorPanel
@ -176,10 +202,31 @@ export function CreateAgentForm({
: "select a hive" : "select a hive"
} }
/> />
<details class="create-agent-section">
<summary>resource caps</summary>
<div class="create-agent-section-body">
<LimitsFields
cpu={cpu}
memory={memory}
onCpu={setCpu}
onMemory={setMemory}
/>
</div>
</details>
<details class="create-agent-section">
<summary>inference</summary>
<div class="create-agent-section-body">
<InferenceFields draft={inference} onChange={setInference} />
</div>
</details>
<Button <Button
variant="primary" variant="primary"
type="submit" type="submit"
disabled={result.status === "submitting" || !hivesReady} disabled={
result.status === "submitting" ||
!hivesReady ||
inferenceProblem !== null
}
> >
{result.status === "submitting" ? "creating…" : "create"} {result.status === "submitting" ? "creating…" : "create"}
</Button> </Button>

View file

@ -51,7 +51,7 @@ pub struct Caps {
impl Caps { impl Caps {
/// The value to store, or why it is refused. /// The value to store, or why it is refused.
fn into_stored(self) -> Result<AgentCaps, String> { pub(crate) fn into_stored(self) -> Result<AgentCaps, String> {
Ok(AgentCaps { Ok(AgentCaps {
cpu_quota: self.cpu_quota.as_deref().map(cpu_quota).transpose()?, cpu_quota: self.cpu_quota.as_deref().map(cpu_quota).transpose()?,
memory_max: self.memory_max.as_deref().map(memory_max).transpose()?, memory_max: self.memory_max.as_deref().map(memory_max).transpose()?,
@ -253,18 +253,28 @@ pub(crate) async fn put_agent_limits(
&format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"), &format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"),
)); ));
} }
let store = open_or_create(&client).await?; store(&client, &agent, &value).await?;
Ok(Json(value.into()))
}
/// Replace `agent`'s stored caps with `value`.
pub(crate) async fn store(
client: &async_nats::Client,
agent: &str,
value: &AgentCaps,
) -> Result<(), ProblemDetails> {
let store = open_or_create(client).await?;
for _ in 0..MAX_ATTEMPTS { for _ in 0..MAX_ATTEMPTS {
let (current, revision, undecodable) = read_or_default::<AgentCaps>(&store, &agent) let (current, revision, undecodable) = read_or_default::<AgentCaps>(&store, agent)
.await .await
.map_err(|e| queue_problem(&e))?; .map_err(|e| queue_problem(&e))?;
if (current == value && !undecodable) if (current == *value && !undecodable)
|| write(&store, &agent, &value, revision) || write(&store, agent, value, revision)
.await .await
.map_err(|e| queue_problem(&e))? .map_err(|e| queue_problem(&e))?
{ {
tracing::info!(%agent, ?value, "set agent limits"); tracing::info!(%agent, ?value, "set agent limits");
return Ok(Json(value.into())); return Ok(());
} }
} }
Err(queue_problem(&anyhow::anyhow!( Err(queue_problem(&anyhow::anyhow!(

View file

@ -87,7 +87,7 @@ pub(crate) fn check_model(name: &str, what: &str) -> Result<(), String> {
impl InferenceSettings { impl InferenceSettings {
/// The value to store, or why it is refused. /// The value to store, or why it is refused.
fn into_stored(self) -> Result<AgentInference, String> { pub(crate) fn into_stored(self) -> Result<AgentInference, String> {
if let Some(model) = &self.model { if let Some(model) = &self.model {
check_model(model, "model")?; check_model(model, "model")?;
} }
@ -313,18 +313,28 @@ pub(crate) async fn put_agent_inference(
&format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"), &format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"),
)); ));
} }
let store = open(&client).await?; store(&client, &agent, &value).await?;
Ok(Json(value.into()))
}
/// Replace `agent`'s stored inference settings with `value`.
pub(crate) async fn store(
client: &async_nats::Client,
agent: &str,
value: &AgentInference,
) -> Result<(), ProblemDetails> {
let store = open(client).await?;
for _ in 0..MAX_ATTEMPTS { for _ in 0..MAX_ATTEMPTS {
let (current, revision, undecodable) = read_or_default::<AgentInference>(&store, &agent) let (current, revision, undecodable) = read_or_default::<AgentInference>(&store, agent)
.await .await
.map_err(|e| queue_problem(&e))?; .map_err(|e| queue_problem(&e))?;
if (current == value && !undecodable) if (current == *value && !undecodable)
|| write(&store, &agent, &value, revision) || write(&store, agent, value, revision)
.await .await
.map_err(|e| queue_problem(&e))? .map_err(|e| queue_problem(&e))?
{ {
tracing::info!(%agent, ?value, "set inference settings"); tracing::info!(%agent, ?value, "set inference settings");
return Ok(Json(value.into())); return Ok(());
} }
} }
Err(queue_problem(&anyhow::anyhow!( Err(queue_problem(&anyhow::anyhow!(

View file

@ -1727,6 +1727,15 @@ async fn get_agents_status(
struct CreateAgentRequest { struct CreateAgentRequest {
name: String, name: String,
hive: String, hive: String,
/// CPU and memory caps to set for the agent, as `PUT
/// /api/agents/{name}/limits` takes them. Absent leaves any caps already
/// stored for the name alone.
#[serde(default)]
limits: Option<agent_limits::Caps>,
/// Inference settings to set for the agent, as `PUT
/// /api/agents/{name}/inference` takes them. Absent as `limits`.
#[serde(default)]
inference: Option<inference::InferenceSettings>,
} }
/// Where the queued job landed — a caller polls `/api/jobq/graph` (or /// Where the queued job landed — a caller polls `/api/jobq/graph` (or
@ -1780,9 +1789,9 @@ struct CreateAgentResponse {
request_body = CreateAgentRequest, request_body = CreateAgentRequest,
responses( responses(
(status = 200, description = "job chain queued", body = CreateAgentResponse), (status = 200, description = "job chain queued", body = CreateAgentResponse),
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, or `hive` is not in this swarm (problem+json)", body = String), (status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, or `limits` or `inference` holds a value the matching `PUT` refuses (problem+json)", body = String),
(status = 409, description = "the swarm has already placed `name` on a different hive (problem+json)", body = String), (status = 409, description = "the swarm has already placed `name` on a different hive (problem+json)", body = String),
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown (problem+json)", body = String), (status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits` or `inference` is set and cannot be stored (problem+json)", body = String),
(status = 500, description = "the job chain could not be queued, or another hive's wanted state could not be read (problem+json)", body = String), (status = 500, description = "the job chain could not be queued, or another hive's wanted state could not be read (problem+json)", body = String),
), ),
tag = "agents" tag = "agents"
@ -1795,6 +1804,24 @@ async fn create_agent(
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))? .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string(); .into_string();
// Settings are checked before anything is created, so a bad value stops
// the request rather than leaving an agent without them.
let limits = req
.limits
.map(agent_limits::Caps::into_stored)
.transpose()
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
let inference = req
.inference
.map(inference::InferenceSettings::into_stored)
.transpose()
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
let settings_queue = if limits.is_some() || inference.is_some() {
Some(schedules::queue(&state).map_err(schedules::refused)?)
} else {
None
};
// Two checks, and the second is the one that makes the field worth // Two checks, and the second is the one that makes the field worth
// having: `Ident::parse` says the string is *shaped* like a hive name, // having: `Ident::parse` says the string is *shaped* like a hive name,
// and the roster says it *is* one. Without the roster check a typo is // and the roster says it *is* one. Without the roster check a typo is
@ -1874,6 +1901,16 @@ async fn create_agent(
); );
return Err(error_problem(axum::http::StatusCode::CONFLICT, &detail)); return Err(error_problem(axum::http::StatusCode::CONFLICT, &detail));
} }
// Stored ahead of the job graph: the hive reads these by agent name when it
// runs the agent, and a failed write here leaves nothing created.
if let Some(client) = &settings_queue {
if let Some(limits) = &limits {
agent_limits::store(client, &agent, limits).await?;
}
if let Some(inference) = &inference {
inference::store(client, &agent, inference).await?;
}
}
let mut sched = state let mut sched = state
.jobq .jobq
.lock() .lock()
@ -3658,6 +3695,8 @@ mod tests {
axum::Json(super::CreateAgentRequest { axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1maa".to_owned(), hive: "pr1maa".to_owned(),
limits: None,
inference: None,
}), }),
) )
.await .await
@ -3696,6 +3735,8 @@ mod tests {
axum::Json(super::CreateAgentRequest { axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None,
inference: None,
}), }),
) )
.await .await
@ -3710,6 +3751,72 @@ mod tests {
assert!(queued > 0, "an accepted creation must queue work"); assert!(queued > 0, "an accepted creation must queue work");
} }
/// A request with no settings is the one callers sent before the fields
/// existed, and each field is checked by the same rules as its `PUT`.
#[test]
fn a_creation_request_takes_optional_settings() {
let plain: super::CreateAgentRequest =
serde_json::from_str(r#"{"name":"atlas","hive":"pr1ma"}"#).expect("plain body");
assert!(plain.limits.is_none() && plain.inference.is_none());
let full: super::CreateAgentRequest = serde_json::from_str(
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"},
"inference":{"model":"opus","effort":"high"}}"#,
)
.expect("body with settings");
assert!(full.limits.is_some() && full.inference.is_some());
let typo = serde_json::from_str::<super::CreateAgentRequest>(
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu":"400%"}}"#,
);
assert!(typo.is_err(), "an unknown cap field must be refused");
}
/// A bad cap or model must stop the creation before the graph is queued:
/// the point of taking settings in the creating request is that a refusal
/// leaves no agent without them.
#[tokio::test]
async fn bad_settings_are_refused_before_anything_is_queued() {
use axum::http::StatusCode;
let bad: [(&str, &str); 2] = [
(r#"{"cpu_quota":"lots"}"#, "limits"),
(r#"{"model":"-opus"}"#, "inference"),
];
for (value, field) in bad {
let (state, sched) = state_with_roster();
let body = format!(r#"{{"name":"atlas","hive":"pr1ma","{field}":{value}}}"#);
let req: super::CreateAgentRequest = serde_json::from_str(&body).expect("body parses");
let err = super::create_agent(axum::extract::State(state), axum::Json(req))
.await
.expect_err("a bad setting must be refused");
assert_eq!(
axum::response::IntoResponse::into_response(err).status(),
StatusCode::BAD_REQUEST,
"{field}"
);
assert_eq!(node_count(&sched), 0, "{field}: nothing may be queued");
}
}
/// Settings that cannot be stored, here because there is no queue, refuse
/// the creation rather than leaving an agent without them.
#[tokio::test]
async fn settings_that_cannot_be_stored_refuse_the_creation() {
let (state, sched) = state_with_roster();
let req: super::CreateAgentRequest = serde_json::from_str(
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"}}"#,
)
.expect("body parses");
let err = super::create_agent(axum::extract::State(state), axum::Json(req))
.await
.expect_err("no queue means the caps cannot be stored");
assert_eq!(
axum::response::IntoResponse::into_response(err).status(),
axum::http::StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(node_count(&sched), 0, "nothing may be queued");
}
/// `state_with_roster` with a second hive, `sec0nd`. /// `state_with_roster` with a second hive, `sec0nd`.
fn state_with_two_hives() -> (super::AppState, SharedSched) { fn state_with_two_hives() -> (super::AppState, SharedSched) {
let (state, sched) = state_with_roster(); let (state, sched) = state_with_roster();
@ -3735,6 +3842,8 @@ mod tests {
axum::Json(super::CreateAgentRequest { axum::Json(super::CreateAgentRequest {
name: name.to_owned(), name: name.to_owned(),
hive: hive.to_owned(), hive: hive.to_owned(),
limits: None,
inference: None,
}), }),
) )
.await .await
@ -4855,6 +4964,8 @@ mod tests {
axum::Json(super::CreateAgentRequest { axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None,
inference: None,
}), }),
) )
.await .await
@ -4906,6 +5017,8 @@ mod tests {
axum::Json(super::CreateAgentRequest { axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None,
inference: None,
}), }),
) )
.await .await
@ -4953,6 +5066,8 @@ mod tests {
axum::Json(super::CreateAgentRequest { axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None,
inference: None,
}), }),
) )
.await .await
@ -5008,6 +5123,8 @@ mod tests {
axum::Json(super::CreateAgentRequest { axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None,
inference: None,
}), }),
) )
.await .await
@ -5210,6 +5327,8 @@ mod tests {
axum::Json(super::CreateAgentRequest { axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(), name: "atlas".to_owned(),
hive: "pr1ma".to_owned(), hive: "pr1ma".to_owned(),
limits: None,
inference: None,
}), }),
) )
.await .await