Watch
0
0
Fork
You've already forked hyperhive
0

archetype flows behave like the built-ins: switch off per archetype or per agent, board cards, duplicate

The document's disabled list takes archetype:<archetype>/<flow> (off for every member) and the same with @<agent> (off for one member); a key naming nothing is ignored. GET /api/notify/flows lists the archetype flows with their members, the board draws a card for each with a switch, a checkbox per agent and duplicate, and the dry run counts their hits. The flows keep following the archetype.
This commit is contained in:
atlas 2026-11-11 19:08:19 +01:00 • committed by mara
commit 68c24643f6
2 changed files with 908 additions and 102 deletions

View file

@ -130,6 +130,24 @@ pub(crate) async fn of_agents(
Ok(found)
}
/// The archetype of every agent that has one, by agent name.
pub(crate) async fn all(
client: &async_nats::Client,
) -> anyhow::Result<std::collections::BTreeMap<String, ArchetypeRef>> {
use futures_util::TryStreamExt as _;
let store = agent_archetype::open_or_create(client).await?;
let mut keys = store.keys().await?;
let mut found = std::collections::BTreeMap::new();
while let Some(agent) = keys.try_next().await? {
let (archetype, _, _) = read_or_default::<Option<ArchetypeRef>>(&store, &agent).await?;
if let Some(archetype) = archetype {
found.insert(agent, archetype);
}
}
Ok(found)
}
/// Delete a destroyed agent's key, so the next agent of that name is not
/// built from this one's archetype. A failure is logged and does not stop the
/// destroy, as in `agent_permissions::clear_on_destroy`.

View file

@ -20,11 +20,14 @@ use axum::extract::rejection::JsonRejection;
use axum::http::StatusCode;
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use swarm_queue_client::archetypes::{self, SourceRecord, flows_of};
use swarm_wire::notify_flows::{self, DecodeError, Document, Flow};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem, wanted_error_status};
use crate::schedules::{queue, read_with, refused, write_revision};
use crate::schedules::{queue, read_or_default, read_with, refused, write_revision};
/// How many times a losing writer re-reads and re-applies, as in
/// `wanted::MAX_ATTEMPTS`.
@ -36,17 +39,38 @@ pub(crate) struct FlowsView {
/// The revision of the stored document, to send back in a `PUT`; `0` when
/// none is stored.
revision: u64,
/// The ids of the built-in flows that are switched off.
/// The ids of the built-in flows that are switched off, and the keys of the
/// archetype flows that are.
disabled_builtins: Vec<String>,
/// The built-in flows this build ships, all of them, switched off or not.
builtins: Vec<Flow>,
/// The custom flows.
flows: Vec<Flow>,
/// The flows archetypes carry, each with the agents it runs for. They follow
/// their archetype, so they are not in the document; a `PUT` switches one off
/// with its `key` in `disabled_builtins`. An older controller's answer has
/// none.
archetype_flows: Vec<ArchetypeFlowView>,
/// What this build accepts: source events, fields, node kinds and limits,
/// for an editor to offer. An older controller's answer has none.
schema: notify_flows::Schema,
}
/// One flow an archetype carries.
#[derive(Debug, Clone, PartialEq, Serialize, ToSchema)]
pub(crate) struct ArchetypeFlowView {
/// The archetype's name.
archetype: String,
/// What names the flow in `disabled_builtins` (off for every member), in the
/// router's log and in a todo's source. `<key>@<agent>` in
/// `disabled_builtins` switches it off for one member.
key: String,
/// The flow as the archetype declares it, under its own `id`.
flow: Flow,
/// The agents of the archetype, by name.
members: Vec<String>,
}
/// The body of a `PUT`: the whole document.
#[derive(Debug, Clone, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
@ -64,12 +88,13 @@ pub(crate) struct PutFlows {
}
impl FlowsView {
fn of(doc: Document, revision: u64) -> Self {
fn of(doc: Document, revision: u64, archetype_flows: Vec<ArchetypeFlowView>) -> Self {
Self {
revision,
disabled_builtins: doc.disabled_builtins,
builtins: notify_flows::builtins(),
flows: doc.flows,
archetype_flows,
schema: notify_flows::schema(),
}
}
@ -152,12 +177,53 @@ pub(crate) async fn get_flows(
Stored::Nothing => Ok(Json(FlowsView::of(
Document::default(),
revision.unwrap_or(0),
archetype_flows(&client).await,
))),
Stored::Document(doc) => Ok(Json(FlowsView::of(
doc,
revision.unwrap_or(0),
archetype_flows(&client).await,
))),
Stored::Document(doc) => Ok(Json(FlowsView::of(doc, revision.unwrap_or(0)))),
Stored::Unreadable(e) => Err(unreadable(&e)),
}
}
/// The flows the archetypes carry, with their agents. A read that fails is
/// logged and leaves the list empty: the document is still served.
async fn archetype_flows(client: &async_nats::Client) -> Vec<ArchetypeFlowView> {
let found = async {
let refs = crate::agent_archetype::all(client).await?;
let mut records = BTreeMap::new();
if let Some(store) = archetypes::open(client).await? {
let sources: BTreeSet<&str> = refs.values().map(|r| r.source.as_str()).collect();
for source in sources {
let (record, _, _) = read_or_default::<SourceRecord>(&store, source).await?;
records.insert(source.to_owned(), record);
}
}
anyhow::Ok(flows_of(&refs, &records).0)
}
.await;
match found {
Ok(found) => found
.into_iter()
.map(|f| ArchetypeFlowView {
key: f.key(),
archetype: f.archetype,
flow: f.flow,
members: f.members,
})
.collect(),
Err(e) => {
tracing::warn!(
error = %format!("{e:#}"),
"notify flows: reading the archetype flows failed; none shown"
);
Vec::new()
}
}
}
pub(crate) fn unreadable(why: &DecodeError) -> ProblemDetails {
error_problem(
StatusCode::CONFLICT,
@ -239,7 +305,11 @@ pub(crate) async fn put_flows(
custom = doc.flows.len(),
"set the notification router's flows"
);
return Ok(Json(FlowsView::of(doc, written)));
return Ok(Json(FlowsView::of(
doc,
written,
archetype_flows(&client).await,
)));
}
}
Err(queue_problem(&anyhow::anyhow!(
@ -465,4 +535,105 @@ mod tests {
.await
.expect("{number} is a field");
}
/// The archetypes' flows are listed with their agents; their keys go in the
/// disabled list, a key for a flow that is gone is kept, and a key of no
/// valid shape is refused.
#[tokio::test]
async fn archetype_flows_are_listed_and_their_keys_are_saved() {
use swarm_queue_client::agent_archetype::ArchetypeRef;
use swarm_queue_client::archetypes::{ArchetypeMeta, BUILTIN_SOURCE, SourceRecord};
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::state(&client);
let mut members = custom("new-issues");
members.nodes[1].kind = NodeKind::Todo(TodoSink {
to: Recipients::Members,
op: Op::Upsert,
key: "forge:{{ repo }}#{{ number }}:triage".to_owned(),
prefix: false,
text: "triage {{ repo }}#{{ number }}".to_owned(),
});
let record = SourceRecord {
source: BUILTIN_SOURCE.to_owned(),
archetypes: [
(
"triage".to_owned(),
ArchetypeMeta {
description: "d".to_owned(),
flows: vec![members.clone()],
..ArchetypeMeta::default()
},
),
(
"reviewer".to_owned(),
ArchetypeMeta {
description: "d".to_owned(),
..ArchetypeMeta::default()
},
),
]
.into(),
refused: BTreeMap::default(),
};
let records = swarm_queue_client::archetypes::open_or_create(&client)
.await
.expect("records");
records
.put(BUILTIN_SOURCE, serde_json::to_vec(&record).unwrap().into())
.await
.expect("put the record");
let choices = swarm_queue_client::agent_archetype::open_or_create(&client)
.await
.expect("choices");
for (agent, archetype) in [
("alice", "triage"),
("bob", "triage"),
("carol", "reviewer"),
] {
let choice = ArchetypeRef {
source: BUILTIN_SOURCE.to_owned(),
name: archetype.to_owned(),
};
choices
.put(agent, serde_json::to_vec(&choice).unwrap().into())
.await
.expect("put the choice");
}
let Json(view) = get_flows(State(state.clone())).await.expect("get");
assert_eq!(view.archetype_flows.len(), 1, "{:?}", view.archetype_flows);
let listed = &view.archetype_flows[0];
assert_eq!(listed.key, "archetype:triage/new-issues");
assert_eq!(listed.archetype, "triage");
assert_eq!(listed.flow, members);
assert_eq!(listed.members, ["alice", "bob"]);
// Off for everyone, off for one agent, and a key of a flow that is gone.
let keys = [
"archetype:triage/new-issues",
"archetype:triage/new-issues@alice",
"archetype:gone/flow",
];
let Json(saved) = put_flows(State(state.clone()), Ok(body(0, &keys, vec![])))
.await
.expect("the keys are saved");
assert_eq!(saved.disabled_builtins, keys);
assert_eq!(
saved.archetype_flows.len(),
1,
"a PUT answers with them too"
);
for bad in ["archetype:triage", "archetype:/x", "archetype:a/b@", "nope"] {
let refused = put_flows(
State(state.clone()),
Ok(body(saved.revision, &[bad], vec![])),
)
.await
.expect_err(bad);
assert_eq!(refused.status, Some(StatusCode::BAD_REQUEST), "{bad}");
}
}
}