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.
103 lines
4 KiB
Rust
103 lines
4 KiB
Rust
//! An agent's `AGENTS.md`, as the swarm sees it: a read mirror in the
|
|
//! `agents-md` KV bucket, and the edit command that changes the file.
|
|
//!
|
|
//! **The file in the agent's state dir is the truth.** The agent's harness is
|
|
//! the only writer of both the file and its key here: it publishes the file's
|
|
//! text and [`crate::agents_md::Mirror::revision`] when it starts and whenever
|
|
//! the file changes, so the swarm can show the text while the agent is
|
|
//! stopped. An edit from the swarm is a [`crate::agents_md::Apply`] sent on the
|
|
//! agent's control subject, which the harness applies to the file itself and
|
|
//! answers with a [`crate::agents_md::ApplyReply`].
|
|
//!
|
|
//! Keyed by the agent alone, for the reason [`crate::agent_icon`] gives. The
|
|
//! auth callout grants each agent's credential `$KV.agents-md.<agent>` and no
|
|
//! other key; the writer publishes to [`crate::agents_md::subject`] without
|
|
//! opening the bucket, and swarm-controller creates it.
|
|
|
|
#[cfg(feature = "kv")]
|
|
use crate::Error;
|
|
|
|
/// The KV bucket the mirrors are published into, keyed by agent name.
|
|
pub const BUCKET: &str = "agents-md";
|
|
|
|
/// The subject `agent`'s mirror is written on: `$KV.<bucket>.<key>`.
|
|
#[must_use]
|
|
pub fn subject(agent: &str) -> String {
|
|
format!("$KV.{BUCKET}.{agent}")
|
|
}
|
|
|
|
/// The value under an agent's key: its `AGENTS.md` as last published.
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
pub struct Mirror {
|
|
/// The file's text. An agent with no `AGENTS.md` publishes `""`.
|
|
pub content: String,
|
|
/// The git blob id of the file's bytes, what `git hash-object` prints for
|
|
/// it. An [`Apply`] names the revision its diff was made against.
|
|
pub revision: String,
|
|
}
|
|
|
|
/// An edit to the agent's `AGENTS.md`: a unified diff, as `git diff` writes
|
|
/// it (`a/AGENTS.md` and `b/AGENTS.md`), against the text at
|
|
/// [`Self::base_revision`].
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
pub struct Apply {
|
|
/// The [`Mirror::revision`] the diff was made against. The harness
|
|
/// refuses the edit when the file is no longer at it.
|
|
pub base_revision: String,
|
|
/// The diff.
|
|
pub diff: String,
|
|
}
|
|
|
|
/// What the harness answers an [`Apply`] with.
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "snake_case", tag = "outcome")]
|
|
pub enum ApplyReply {
|
|
/// The diff is applied; the file is now at `revision`.
|
|
Applied { revision: String },
|
|
/// The file is not at the base revision, so nothing was applied. It is at
|
|
/// `revision`.
|
|
Stale { revision: String },
|
|
/// `git apply` refused the diff, or the diff touches something other than
|
|
/// `AGENTS.md`. The file is unchanged; `error` is git's own message or
|
|
/// says what the diff touched.
|
|
Rejected { error: String },
|
|
/// The harness could not read or write the file, or run git.
|
|
Failed { error: String },
|
|
}
|
|
|
|
/// Open the bucket, creating it if nothing has yet. `history: 1`, as for
|
|
/// [`crate::agent_icon::open_or_create`]: the swarm wants the current text.
|
|
#[cfg(feature = "kv")]
|
|
pub async fn open_or_create(
|
|
client: &async_nats::Client,
|
|
) -> Result<async_nats::jetstream::kv::Store, Error> {
|
|
let js = async_nats::jetstream::new(client.clone());
|
|
crate::kv::open_or_create(
|
|
&js,
|
|
BUCKET,
|
|
"agents-md",
|
|
"Each agent's AGENTS.md, as its harness last published it".to_owned(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{ApplyReply, subject};
|
|
|
|
/// One token after the bucket, the agent name alone: what the per-agent
|
|
/// grant `$KV.agents-md.{agent}` expands to.
|
|
#[test]
|
|
fn the_published_subject_carries_the_agent_alone() {
|
|
assert_eq!(subject("iris"), "$KV.agents-md.iris");
|
|
}
|
|
|
|
#[test]
|
|
fn a_reply_names_its_outcome() {
|
|
let wire = serde_json::to_string(&ApplyReply::Stale {
|
|
revision: "abc".to_owned(),
|
|
})
|
|
.expect("encodes");
|
|
assert_eq!(wire, r#"{"outcome":"stale","revision":"abc"}"#);
|
|
}
|
|
}
|