read an agent's agents.md through the swarm and edit it with git diffs
The agent's state/AGENTS.md stays the truth and its harness the only writer. The harness mirrors the file into a new `agents-md` KV bucket, and swarm-controller serves that copy, so a read works while the agent is stopped. An edit is a unified diff plus the revision it was made against, sent over the agent's existing control subject; the harness refuses a stale base, applies the diff with `git apply --check` then `git apply` to a temp copy, and writes the file only when both pass. Fixes #5519. Files: - swarm-queue-client/src/agents_md.rs, lib.rs: the bucket name and subject, the mirror value, the `ApplyAgentsMd` control command and its reply. `AgentControl` loses `Copy` because the new variant carries text. - hive-agent/src/swarm_agents_md.rs, main.rs: the mirror task (start, 10 s re-read, immediate republish after an edit) and the apply. - hive-agent/src/swarm_input.rs: the control loop answers both commands. - hive-agent/src/claude_md_watch.rs: its AGENTS.md path is shared. - hive-agent/Cargo.toml, Cargo.lock: sha1 for the git blob id (already in the lock), tempfile moves from dev to runtime for the apply directory. - swarm-controller/src/agents_md.rs, main.rs: the bucket reader and GET/PUT /api/agents/{name}/agents-md; test AppState literals get the new field. - swarm-controller/src/agent_control.rs: the sink hands back raw reply bytes, so each command decodes its own reply type. - swarm-controller-api/src/agents_md.rs: the route bodies, shared with swarmctl. - swarmctl/src/agents_md.rs, main.rs: `swarmctl agent agents-md get|apply`. - nix/host-modules/swarm-nats.nix, nix/module-eval/nats-authelia.nix: the agent's publish grant on its own `$KV.agents-md.<agent>` key. git needs no new package: agent containers enable programs.git, and the harness unit's PATH includes /run/current-system/sw.
This commit is contained in:
parent
f4f459f0b2
commit
88674eb63a
22 changed files with 1296 additions and 47 deletions
|
|
@ -27,8 +27,8 @@ use crate::{AppState, error_problem};
|
|||
/// What became of one command.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Outcome {
|
||||
/// The agent answered.
|
||||
Answered(AgentControlReply),
|
||||
/// The agent answered with this payload, a reply to the command sent.
|
||||
Answered(Vec<u8>),
|
||||
/// Nothing subscribes to the agent's control subject.
|
||||
NotRunning,
|
||||
/// A subscriber held the command and did not answer in time.
|
||||
|
|
@ -50,10 +50,7 @@ impl ControlSink for async_nats::Client {
|
|||
.payload(payload.into())
|
||||
.timeout(Some(swarm_queue_client::AGENT_CONTROL_REPLY_DEADLINE));
|
||||
match self.send_request(subject, request).await {
|
||||
Ok(reply) => match serde_json::from_slice(&reply.payload) {
|
||||
Ok(reply) => Outcome::Answered(reply),
|
||||
Err(e) => Outcome::Failed(format!("the agent's answer was unreadable: {e}")),
|
||||
},
|
||||
Ok(reply) => Outcome::Answered(reply.payload.to_vec()),
|
||||
Err(e) => match e.kind() {
|
||||
RequestErrorKind::TimedOut => Outcome::Unconfirmed,
|
||||
RequestErrorKind::NoResponders => Outcome::NotRunning,
|
||||
|
|
@ -117,7 +114,7 @@ pub(crate) async fn post_agent_cancel(
|
|||
|
||||
/// The agent and who is asking, or the refusal. Everything decided before the
|
||||
/// queue is touched.
|
||||
fn target(
|
||||
pub(crate) fn target(
|
||||
headers: &HeaderMap,
|
||||
agent: &str,
|
||||
) -> Result<(String, String), (StatusCode, &'static str)> {
|
||||
|
|
@ -143,6 +140,12 @@ async fn send(
|
|||
})?;
|
||||
match sink.request(subject.clone(), payload).await {
|
||||
Outcome::Answered(reply) => {
|
||||
let reply: AgentControlReply = serde_json::from_slice(&reply).map_err(|e| {
|
||||
error_problem(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("{agent}'s answer was unreadable: {e}"),
|
||||
)
|
||||
})?;
|
||||
tracing::info!(%agent, %from, ?command, acted = reply.acted, "agent command answered");
|
||||
Ok(Json(CancelResponse { acted: reply.acted }))
|
||||
}
|
||||
|
|
@ -231,10 +234,14 @@ mod tests {
|
|||
async fn the_answer_is_relayed_from_the_agent_s_own_control_subject() {
|
||||
let answers: [(bool, fn() -> Outcome); 2] = [
|
||||
(true, || {
|
||||
Outcome::Answered(AgentControlReply { acted: true })
|
||||
Outcome::Answered(
|
||||
serde_json::to_vec(&AgentControlReply { acted: true }).expect("encodes"),
|
||||
)
|
||||
}),
|
||||
(false, || {
|
||||
Outcome::Answered(AgentControlReply { acted: false })
|
||||
Outcome::Answered(
|
||||
serde_json::to_vec(&AgentControlReply { acted: false }).expect("encodes"),
|
||||
)
|
||||
}),
|
||||
];
|
||||
for (acted, answer) in answers {
|
||||
|
|
|
|||
|
|
@ -493,6 +493,7 @@ pub(crate) mod tests {
|
|||
wanted: Some(Arc::new(WantedWriter::new(client.clone()))),
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
agents_md: None,
|
||||
reported_inference: None,
|
||||
agent_login: None,
|
||||
jobq: Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
|
|
|
|||
436
swarm-controller/src/agents_md.rs
Normal file
436
swarm-controller/src/agents_md.rs
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
//! `GET` and `PUT /api/agents/{name}/agents-md`: an agent's `AGENTS.md`.
|
||||
//!
|
||||
//! **The file in the agent's state dir is the truth, and only its harness
|
||||
//! writes it.** A read serves the copy the harness publishes into the
|
||||
//! `agents-md` bucket at start and on every change
|
||||
//! ([`swarm_queue_client::agents_md`]), so it answers for a stopped agent
|
||||
//! too. An edit is a git diff sent to the harness as a command on its
|
||||
//! control subject ([`crate::agent_control`]); the harness applies it to the
|
||||
//! file and says whether it did, so an edit needs the agent running.
|
||||
//!
|
||||
//! No hive is on either path.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse as _, Response};
|
||||
use problem_details::ProblemDetails;
|
||||
use swarm_controller_api::agents_md::{AgentsMdEdit, AgentsMdSaved, AgentsMdView};
|
||||
use swarm_queue_client::AgentControl;
|
||||
use swarm_queue_client::agents_md::{Apply, ApplyReply, Mirror};
|
||||
|
||||
use crate::agent_control::{ControlSink, Outcome};
|
||||
use crate::{AppState, error_problem, wanted_error_status};
|
||||
|
||||
/// Reads the `agents-md` bucket, the way [`crate::agent_icon::AgentIconReader`]
|
||||
/// reads the icon bucket, and creates it: the agents writing it cannot.
|
||||
pub struct AgentsMdReader {
|
||||
client: async_nats::Client,
|
||||
store: tokio::sync::OnceCell<async_nats::jetstream::kv::Store>,
|
||||
}
|
||||
|
||||
impl AgentsMdReader {
|
||||
#[must_use]
|
||||
pub fn new(client: async_nats::Client) -> Self {
|
||||
Self {
|
||||
client,
|
||||
store: tokio::sync::OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn store(
|
||||
&self,
|
||||
) -> std::result::Result<&async_nats::jetstream::kv::Store, swarm_queue_client::Error> {
|
||||
self.store
|
||||
.get_or_try_init(|| swarm_queue_client::agents_md::open_or_create(&self.client))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Open the bucket, creating it, as soon as the queue is connected. Until
|
||||
/// then every agent's write is refused and retried.
|
||||
pub async fn create_when_connected(&self) {
|
||||
const POLL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
if swarm_queue_client::ensure_connected(&self.client).is_ok() {
|
||||
match self.store().await {
|
||||
Ok(_) => return,
|
||||
Err(e) => tracing::warn!(
|
||||
error = swarm_queue_client::chain(&e),
|
||||
"creating the agents-md bucket failed; retrying"
|
||||
),
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// `agent`'s published `AGENTS.md`, or `None` when it has published none.
|
||||
async fn get(&self, agent: &str) -> anyhow::Result<Option<Mirror>> {
|
||||
use anyhow::Context as _;
|
||||
|
||||
// An unconnected client hangs on a JetStream request rather than
|
||||
// failing it.
|
||||
swarm_queue_client::ensure_connected(&self.client)?;
|
||||
let store = self.store().await?;
|
||||
let Some(bytes) = store
|
||||
.get(agent)
|
||||
.await
|
||||
.with_context(|| format!("reading the agents-md entry for {agent}"))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mirror = serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("the agents-md entry for {agent} is not a mirror"))?;
|
||||
Ok(Some(mirror))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an agent's `AGENTS.md` as its harness last published it.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/agents/{name}/agents-md",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
responses(
|
||||
(status = 200, description = "the file's text and revision, as the agent last \
|
||||
published them; answered while the agent is stopped too", body = AgentsMdView),
|
||||
(status = 400, description = "not an identifier (problem+json)", body = String),
|
||||
(status = 404, description = "this agent has published no AGENTS.md: its harness \
|
||||
has not run since it started mirroring the file (problem+json)", body = String),
|
||||
(status = 500, description = "the bucket could not be read (problem+json)", body = String),
|
||||
(status = 503, description = "no swarm queue is wired up, or it is not connected \
|
||||
(problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub(crate) async fn get_agents_md(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<AgentsMdView>, ProblemDetails> {
|
||||
let Some(reader) = state.agents_md.as_ref() else {
|
||||
return Err(error_problem(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"no swarm queue is wired up on this host",
|
||||
));
|
||||
};
|
||||
let agent = hive_types::Ident::parse(&name)
|
||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let mirror = reader.get(&agent).await.map_err(|e| {
|
||||
tracing::warn!(%agent, error = %format!("{e:#}"), "reading the agents-md entry failed");
|
||||
error_problem(wanted_error_status(&e), &format!("{e:#}"))
|
||||
})?;
|
||||
let Some(Mirror { content, revision }) = mirror else {
|
||||
return Err(error_problem(
|
||||
StatusCode::NOT_FOUND,
|
||||
"this agent has published no AGENTS.md",
|
||||
));
|
||||
};
|
||||
Ok(Json(AgentsMdView { content, revision }))
|
||||
}
|
||||
|
||||
/// The revision a 409 names, as a problem+json extension member.
|
||||
#[derive(serde::Serialize)]
|
||||
struct CurrentRevision {
|
||||
revision: String,
|
||||
}
|
||||
|
||||
/// Edit an agent's `AGENTS.md` with a git diff, which the agent's harness
|
||||
/// applies to the file.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/agents/{name}/agents-md",
|
||||
params(("name" = String, Path, description = "agent whose AGENTS.md to edit")),
|
||||
request_body = AgentsMdEdit,
|
||||
responses(
|
||||
(status = 200, description = "the diff is applied; the body is the file's new \
|
||||
revision", body = AgentsMdSaved),
|
||||
(status = 400, description = "not an identifier (problem+json)", body = String),
|
||||
(status = 401, description = "the request carries no `Remote-User` (problem+json)",
|
||||
body = String),
|
||||
(status = 409, description = "the file is no longer at `base_revision`, so nothing \
|
||||
was applied; `revision` in the problem+json body is the one it is at", body = String),
|
||||
(status = 422, description = "`git apply` refused the diff, or it touches something \
|
||||
other than AGENTS.md; `detail` is git's message and the file is unchanged \
|
||||
(problem+json)", body = String),
|
||||
(status = 503, description = "the agent is not running, or its harness is not \
|
||||
connected to the swarm queue, or no swarm queue is configured on this host or it \
|
||||
could not be reached (problem+json)", body = String),
|
||||
(status = 504, description = "the harness held the edit and did not answer in time, \
|
||||
so whether it was applied is unknown (problem+json)", body = String),
|
||||
(status = 500, description = "the queue refused or failed the request, or the harness \
|
||||
could not read or write the file (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub(crate) async fn put_agents_md(
|
||||
State(state): State<AppState>,
|
||||
Path(agent): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(edit): Json<AgentsMdEdit>,
|
||||
) -> Result<Json<AgentsMdSaved>, Response> {
|
||||
let problem = |s: StatusCode, d: &str| error_problem(s, d).into_response();
|
||||
let (agent, from) =
|
||||
crate::agent_control::target(&headers, &agent).map_err(|(s, d)| problem(s, d))?;
|
||||
let Some(status) = state.status.as_ref() else {
|
||||
return Err(problem(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"no swarm queue is configured on this host",
|
||||
));
|
||||
};
|
||||
let client = status.queue_client();
|
||||
swarm_queue_client::ensure_connected(&client).map_err(|e| {
|
||||
problem(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
&swarm_queue_client::chain(&e),
|
||||
)
|
||||
})?;
|
||||
send(&client, &agent, &from, edit).await
|
||||
}
|
||||
|
||||
async fn send(
|
||||
sink: &impl ControlSink,
|
||||
agent: &str,
|
||||
from: &str,
|
||||
edit: AgentsMdEdit,
|
||||
) -> Result<Json<AgentsMdSaved>, Response> {
|
||||
let problem = |s: StatusCode, d: &str| error_problem(s, d).into_response();
|
||||
let subject = swarm_queue_client::agent_control_subject(agent);
|
||||
let command = AgentControl::ApplyAgentsMd(Apply {
|
||||
base_revision: edit.base_revision,
|
||||
diff: edit.diff,
|
||||
});
|
||||
let payload = serde_json::to_vec(&command).map_err(|e| {
|
||||
problem(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("encoding the edit failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
let reply = match sink.request(subject.clone(), payload).await {
|
||||
Outcome::Answered(reply) => serde_json::from_slice::<ApplyReply>(&reply).map_err(|e| {
|
||||
problem(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("{agent}'s answer was unreadable: {e}"),
|
||||
)
|
||||
})?,
|
||||
Outcome::NotRunning => {
|
||||
return Err(problem(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
&format!(
|
||||
"{agent} is not running: its harness is not listening for commands, so \
|
||||
AGENTS.md cannot be edited until it is"
|
||||
),
|
||||
));
|
||||
}
|
||||
Outcome::Unconfirmed => {
|
||||
tracing::warn!(%agent, %from, "agents-md edit unanswered");
|
||||
return Err(problem(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
&format!("{agent} did not answer in time: whether the edit was applied is unknown"),
|
||||
));
|
||||
}
|
||||
Outcome::Failed(e) => {
|
||||
tracing::warn!(%agent, %subject, error = %e, "agents-md edit failed");
|
||||
return Err(problem(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("sending to {subject} failed: {e}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
match reply {
|
||||
ApplyReply::Applied { revision } => {
|
||||
tracing::info!(%agent, %from, %revision, "agents-md edit applied");
|
||||
Ok(Json(AgentsMdSaved { revision }))
|
||||
}
|
||||
ApplyReply::Stale { revision } => {
|
||||
Err(ProblemDetails::from_status_code(StatusCode::CONFLICT)
|
||||
.with_detail(format!(
|
||||
"{agent}'s AGENTS.md changed since the edit was made: it is at {revision}. \
|
||||
Reload it and make the edit again"
|
||||
))
|
||||
.with_extensions(CurrentRevision { revision })
|
||||
.into_response())
|
||||
}
|
||||
ApplyReply::Rejected { error } => Err(problem(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
&format!("the diff does not apply to {agent}'s AGENTS.md: {error}"),
|
||||
)),
|
||||
ApplyReply::Failed { error } => {
|
||||
tracing::warn!(%agent, %error, "agents-md edit failed in the harness");
|
||||
Err(problem(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("{agent} could not apply the edit: {error}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use swarm_controller_api::agents_md::AgentsMdEdit;
|
||||
use swarm_queue_client::AgentControl;
|
||||
use swarm_queue_client::agents_md::{Apply, ApplyReply};
|
||||
|
||||
use super::send;
|
||||
use crate::agent_control::{ControlSink, Outcome};
|
||||
|
||||
/// Answers every command with one fixed outcome and records what it got.
|
||||
struct FakeSink {
|
||||
answer: Box<dyn Fn() -> Outcome + Send + Sync>,
|
||||
got: Mutex<Vec<(String, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl FakeSink {
|
||||
fn replying(reply: &ApplyReply) -> Self {
|
||||
let bytes = serde_json::to_vec(reply).expect("encodes");
|
||||
Self::new(move || Outcome::Answered(bytes.clone()))
|
||||
}
|
||||
|
||||
fn new(answer: impl Fn() -> Outcome + Send + Sync + 'static) -> Self {
|
||||
Self {
|
||||
answer: Box::new(answer),
|
||||
got: Mutex::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlSink for FakeSink {
|
||||
async fn request(&self, subject: String, payload: Vec<u8>) -> Outcome {
|
||||
self.got
|
||||
.lock()
|
||||
.expect("unpoisoned")
|
||||
.push((subject, payload));
|
||||
(self.answer)()
|
||||
}
|
||||
}
|
||||
|
||||
fn edit() -> AgentsMdEdit {
|
||||
AgentsMdEdit {
|
||||
base_revision: "abc".to_owned(),
|
||||
diff: "--- a/AGENTS.md\n".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn body(resp: axum::response::Response) -> serde_json::Value {
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
serde_json::from_slice(&bytes).expect("json")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_edit_goes_to_the_agent_s_control_subject_and_its_revision_comes_back() {
|
||||
let sink = FakeSink::replying(&ApplyReply::Applied {
|
||||
revision: "def".to_owned(),
|
||||
});
|
||||
let saved = send(&sink, "atlas", "mara", edit()).await.expect("applied");
|
||||
assert_eq!(saved.0.revision, "def");
|
||||
let got = sink.got.lock().expect("unpoisoned");
|
||||
assert_eq!(got[0].0, "$SWARM.control.atlas");
|
||||
let sent: AgentControl = serde_json::from_slice(&got[0].1).expect("a command");
|
||||
assert_eq!(
|
||||
sent,
|
||||
AgentControl::ApplyAgentsMd(Apply {
|
||||
base_revision: "abc".to_owned(),
|
||||
diff: "--- a/AGENTS.md\n".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stale_base_is_409_naming_the_current_revision() {
|
||||
let sink = FakeSink::replying(&ApplyReply::Stale {
|
||||
revision: "fff".to_owned(),
|
||||
});
|
||||
let resp = send(&sink, "atlas", "mara", edit())
|
||||
.await
|
||||
.expect_err("stale");
|
||||
assert_eq!(resp.status(), StatusCode::CONFLICT);
|
||||
assert_eq!(body(resp).await["revision"], "fff");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_diff_git_refuses_is_422_with_git_s_message() {
|
||||
let sink = FakeSink::replying(&ApplyReply::Rejected {
|
||||
error: "error: patch failed: AGENTS.md:1".to_owned(),
|
||||
});
|
||||
let resp = send(&sink, "atlas", "mara", edit())
|
||||
.await
|
||||
.expect_err("rejected");
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let detail = body(resp).await["detail"]
|
||||
.as_str()
|
||||
.expect("detail")
|
||||
.to_owned();
|
||||
assert!(detail.contains("patch failed: AGENTS.md:1"), "{detail}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_subscriber_is_503_and_the_rest_are_504_and_500() {
|
||||
let status = |sink: FakeSink| async move {
|
||||
send(&sink, "atlas", "mara", edit())
|
||||
.await
|
||||
.expect_err("not a success")
|
||||
.status()
|
||||
};
|
||||
assert_eq!(
|
||||
status(FakeSink::new(|| Outcome::NotRunning)).await,
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
assert_eq!(
|
||||
status(FakeSink::new(|| Outcome::Unconfirmed)).await,
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
);
|
||||
assert_eq!(
|
||||
status(FakeSink::new(|| Outcome::Failed("refused".to_owned()))).await,
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
assert_eq!(
|
||||
status(FakeSink::replying(&ApplyReply::Failed {
|
||||
error: "disk full".to_owned()
|
||||
}))
|
||||
.await,
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
// A cancel's answer is not an edit's.
|
||||
assert_eq!(
|
||||
status(FakeSink::new(|| Outcome::Answered(
|
||||
br#"{"acted":true}"#.to_vec()
|
||||
)))
|
||||
.await,
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
/// Through a real `nats-server`: nothing is served before the harness
|
||||
/// publishes, and its publish, straight to the key's subject as the
|
||||
/// harness sends it, is what a read returns.
|
||||
#[tokio::test]
|
||||
async fn a_read_serves_what_the_harness_published() {
|
||||
use swarm_queue_client::agents_md::{Mirror, subject};
|
||||
|
||||
let nats = crate::test_nats::TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let reader = super::AgentsMdReader::new(client.clone());
|
||||
reader.create_when_connected().await;
|
||||
assert_eq!(reader.get("atlas").await.expect("read"), None);
|
||||
|
||||
let mirror = Mirror {
|
||||
content: "# atlas\n".to_owned(),
|
||||
revision: "abc".to_owned(),
|
||||
};
|
||||
let js = async_nats::jetstream::new(client.clone());
|
||||
js.publish(
|
||||
subject("atlas"),
|
||||
serde_json::to_vec(&mirror).expect("encodes").into(),
|
||||
)
|
||||
.await
|
||||
.expect("sent")
|
||||
.await
|
||||
.expect("acked");
|
||||
assert_eq!(reader.get("atlas").await.expect("read"), Some(mirror));
|
||||
assert_eq!(reader.get("iris").await.expect("read"), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,7 @@ mod tests {
|
|||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
agents_md: None,
|
||||
reported_inference: None,
|
||||
agent_login: None,
|
||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ mod tests {
|
|||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
agents_md: None,
|
||||
reported_inference: None,
|
||||
agent_login: None,
|
||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
|
|
|
|||
|
|
@ -962,6 +962,7 @@ pub(crate) mod tests {
|
|||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
agents_md: None,
|
||||
reported_inference: None,
|
||||
agent_login: None,
|
||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ mod agent_permissions;
|
|||
mod agent_renewal;
|
||||
mod agent_state_stream;
|
||||
mod agent_status;
|
||||
mod agents_md;
|
||||
mod alerts;
|
||||
mod archetypes;
|
||||
mod auth;
|
||||
|
|
@ -954,6 +955,9 @@ struct AppState {
|
|||
/// connection, several consumers" rationale as `agent_status` above,
|
||||
/// and `None` in exactly the state `status` is.
|
||||
agent_icons: Option<Arc<agent_icon::AgentIconReader>>,
|
||||
/// Each agent's published `AGENTS.md`, on `status`'s connection like
|
||||
/// `agent_icons`, and `None` in exactly the state `status` is.
|
||||
agents_md: Option<Arc<agents_md::AgentsMdReader>>,
|
||||
/// What each agent last reported running on, from its turn-state
|
||||
/// headers on `status`'s connection. `None` in exactly the state
|
||||
/// `status` is.
|
||||
|
|
@ -1325,6 +1329,17 @@ fn agent_icon_reader(
|
|||
Some(reader)
|
||||
}
|
||||
|
||||
/// The `AGENTS.md` reader, sharing the status reader's connection, and
|
||||
/// creating the bucket, as [`agent_icon_reader`] does for icons.
|
||||
fn agents_md_reader(
|
||||
status: Option<&Arc<status::StatusReader>>,
|
||||
) -> Option<Arc<agents_md::AgentsMdReader>> {
|
||||
let reader = Arc::new(agents_md::AgentsMdReader::new(status?.queue_client()));
|
||||
let creating = Arc::clone(&reader);
|
||||
tokio::spawn(async move { creating.create_when_connected().await });
|
||||
Some(reader)
|
||||
}
|
||||
|
||||
/// What each agent last reported running on, read on the status reader's
|
||||
/// connection — same rationale as [`wanted_writer`].
|
||||
fn reported_inference(
|
||||
|
|
@ -3612,6 +3627,7 @@ async fn main() -> Result<()> {
|
|||
wanted: wanted_writer(status.as_ref()),
|
||||
agent_status: agent_status_reader(status.as_ref()),
|
||||
agent_icons: agent_icon_reader(status.as_ref()),
|
||||
agents_md: agents_md_reader(status.as_ref()),
|
||||
reported_inference: reported_inference(status.as_ref()),
|
||||
agent_login: agent_login(status.as_ref()),
|
||||
runs: review_classifier::runs::Runs::Live(status.as_ref().map(|s| s.queue_client())),
|
||||
|
|
@ -3630,8 +3646,7 @@ async fn main() -> Result<()> {
|
|||
alerts: alerts::from_env(),
|
||||
};
|
||||
|
||||
let app = build_app(state);
|
||||
axum::serve(listener, app)
|
||||
axum::serve(listener, build_app(state))
|
||||
.await
|
||||
.context("serving swarm-controller")
|
||||
}
|
||||
|
|
@ -3654,6 +3669,7 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
.routes(routes!(get_jobq_rollup))
|
||||
.routes(routes!(get_agent_config_pr))
|
||||
.routes(routes!(get_agent_icon))
|
||||
.routes(routes!(agents_md::get_agents_md, agents_md::put_agents_md))
|
||||
.routes(routes!(
|
||||
inference::get_agent_inference,
|
||||
inference::put_agent_inference,
|
||||
|
|
@ -3833,6 +3849,7 @@ mod tests {
|
|||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
agents_md: None,
|
||||
reported_inference: None,
|
||||
agent_login: None,
|
||||
jobq: std::sync::Arc::clone(&sched),
|
||||
|
|
|
|||
|
|
@ -457,6 +457,7 @@ mod tests {
|
|||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
agents_md: None,
|
||||
reported_inference: None,
|
||||
agent_login: None,
|
||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
|
|
|
|||
Loading…
Reference in a new issue