Survey + fixes so the pipeline passes at the Docker level (no k8s).
- Remove k8s: drop the `sandbox-k8s` job (kind/Calico/--features k8s-tests) and the
"Helm chart lints" gate step. release.yml was already k8s-clean.
- Rust job:
- `cargo fmt --all` — fix pre-existing formatting drift (fmt --check was failing).
- clippy -D warnings: fix 3 lib warnings (cm-brain sort_by_key→Reverse, cm-api
fleet.rs doc list indentation, node_rules map_or→is_none_or).
- Regenerate the .sqlx offline cache (was missing the cm-runtime run_loop test
query → offline compile failed). DB-backed tests use testcontainers at runtime.
- Set SQLX_OFFLINE=true on the rust + e2e jobs so query! macros compile against
the committed cache deterministically (no DB needed at compile time).
- Frontend job:
- Fix the 1 ESLint error (useAgentTelemetry: no setState-synchronously-in-effect;
tag the slice with agentId + derive null on mismatch).
- Fix 2 stale panel-params tests (`terminal` is a valid app id now; assert the
current APP_IDS + use a genuinely-unknown id for the reject case).
Verified locally: fmt clean, clippy --all-targets -D warnings clean (offline),
frontend lint 0 errors, tsc clean, 86/86 frontend tests pass, build OK.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
207 lines
6.1 KiB
Rust
207 lines
6.1 KiB
Rust
//! The [`TopologyGraph`] data model: role-slot nodes and typed edges.
|
|
|
|
use std::collections::{BTreeMap, HashSet};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::kind::TopologyKind;
|
|
use crate::TopologyError;
|
|
|
|
/// A role slot in the topology. At run time a node is bound to a concrete
|
|
/// claw (`AgentId`); here it is purely structural.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Node {
|
|
/// Stable identifier, unique within the graph.
|
|
pub id: String,
|
|
/// The role this slot plays (e.g. "orchestrator", "researcher").
|
|
pub role: String,
|
|
/// Optional depth hint (0 = top) for layered topologies.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub level: Option<u32>,
|
|
/// Free-form attributes (model, budget, persona…).
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub attrs: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl Node {
|
|
/// Convenience constructor for a bare role slot.
|
|
pub fn new(id: impl Into<String>, role: impl Into<String>) -> Self {
|
|
Node {
|
|
id: id.into(),
|
|
role: role.into(),
|
|
level: None,
|
|
attrs: BTreeMap::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The semantic of an edge — how the `from` node relates to `to`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum EdgeKind {
|
|
/// `from` delegates work down to `to`.
|
|
DelegatesTo,
|
|
/// `from` reports results up to `to`.
|
|
ReportsTo,
|
|
/// `from` pipes its output into `to` (pipeline stage).
|
|
PipesTo,
|
|
/// `from` and `to` are collaborating peers.
|
|
PeersWith,
|
|
/// `from` routes/dispatches to `to` (hub/router).
|
|
RoutesTo,
|
|
/// `from` bids work out to `to` (market).
|
|
BidsTo,
|
|
/// `from` and `to` share a workspace (blackboard).
|
|
ReadsWrites,
|
|
}
|
|
|
|
/// A directed relationship between two nodes.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Edge {
|
|
/// Source node id.
|
|
pub from: String,
|
|
/// Target node id.
|
|
pub to: String,
|
|
/// Relationship semantic.
|
|
pub kind: EdgeKind,
|
|
}
|
|
|
|
/// A full topology: a declared [`TopologyKind`] plus its node/edge graph.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct TopologyGraph {
|
|
/// The declared topology kind (the classifier can also infer one).
|
|
pub kind: TopologyKind,
|
|
/// Role-slot nodes.
|
|
pub nodes: Vec<Node>,
|
|
/// Directed, typed edges.
|
|
pub edges: Vec<Edge>,
|
|
}
|
|
|
|
impl TopologyGraph {
|
|
/// Build a graph and validate it in one step.
|
|
pub fn new(
|
|
kind: TopologyKind,
|
|
nodes: Vec<Node>,
|
|
edges: Vec<Edge>,
|
|
) -> Result<Self, TopologyError> {
|
|
let g = TopologyGraph { kind, nodes, edges };
|
|
g.validate()?;
|
|
Ok(g)
|
|
}
|
|
|
|
/// Ensure the graph is well-formed: non-empty, unique ids, edges resolve.
|
|
pub fn validate(&self) -> Result<(), TopologyError> {
|
|
if self.nodes.is_empty() {
|
|
return Err(TopologyError::Empty);
|
|
}
|
|
let mut ids = HashSet::with_capacity(self.nodes.len());
|
|
for n in &self.nodes {
|
|
if !ids.insert(n.id.as_str()) {
|
|
return Err(TopologyError::DuplicateNode(n.id.clone()));
|
|
}
|
|
}
|
|
for e in &self.edges {
|
|
if !ids.contains(e.from.as_str()) {
|
|
return Err(TopologyError::UnknownNode(e.from.clone()));
|
|
}
|
|
if !ids.contains(e.to.as_str()) {
|
|
return Err(TopologyError::UnknownNode(e.to.clone()));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Node count.
|
|
pub fn order(&self) -> usize {
|
|
self.nodes.len()
|
|
}
|
|
|
|
/// Edge count.
|
|
pub fn size(&self) -> usize {
|
|
self.edges.len()
|
|
}
|
|
|
|
/// Undirected adjacency as index sets (used by metrics). Self-loops and
|
|
/// duplicate edges are collapsed. Returns `(index_of, neighbors)`.
|
|
pub(crate) fn undirected_adjacency(&self) -> (BTreeMap<&str, usize>, Vec<HashSet<usize>>) {
|
|
let index_of: BTreeMap<&str, usize> = self
|
|
.nodes
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, n)| (n.id.as_str(), i))
|
|
.collect();
|
|
let mut adj = vec![HashSet::new(); self.nodes.len()];
|
|
for e in &self.edges {
|
|
if let (Some(&a), Some(&b)) =
|
|
(index_of.get(e.from.as_str()), index_of.get(e.to.as_str()))
|
|
{
|
|
if a != b {
|
|
adj[a].insert(b);
|
|
adj[b].insert(a);
|
|
}
|
|
}
|
|
}
|
|
(index_of, adj)
|
|
}
|
|
|
|
/// Directed in-degree per node index (used for hierarchy detection).
|
|
pub(crate) fn in_degrees(&self) -> Vec<usize> {
|
|
let index_of: BTreeMap<&str, usize> = self
|
|
.nodes
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, n)| (n.id.as_str(), i))
|
|
.collect();
|
|
let mut indeg = vec![0usize; self.nodes.len()];
|
|
for e in &self.edges {
|
|
if let Some(&b) = index_of.get(e.to.as_str()) {
|
|
if index_of.get(e.from.as_str()).copied() != Some(b) {
|
|
indeg[b] += 1;
|
|
}
|
|
}
|
|
}
|
|
indeg
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn validate_rejects_empty() {
|
|
let g = TopologyGraph {
|
|
kind: TopologyKind::Flat,
|
|
nodes: vec![],
|
|
edges: vec![],
|
|
};
|
|
assert_eq!(g.validate(), Err(TopologyError::Empty));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_unknown_edge() {
|
|
let err = TopologyGraph::new(
|
|
TopologyKind::Pipeline,
|
|
vec![Node::new("a", "x")],
|
|
vec![Edge {
|
|
from: "a".into(),
|
|
to: "ghost".into(),
|
|
kind: EdgeKind::PipesTo,
|
|
}],
|
|
)
|
|
.unwrap_err();
|
|
assert_eq!(err, TopologyError::UnknownNode("ghost".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_duplicate_node() {
|
|
let err = TopologyGraph::new(
|
|
TopologyKind::Flat,
|
|
vec![Node::new("a", "x"), Node::new("a", "y")],
|
|
vec![],
|
|
)
|
|
.unwrap_err();
|
|
assert_eq!(err, TopologyError::DuplicateNode("a".into()));
|
|
}
|
|
}
|