mod build;
mod filter;
pub use build::{build, build_tree};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::ser::SerializeStruct;
use serde::{Serialize, Serializer};
use crate::config::Scope;
use crate::model::anomaly::Anomaly;
use crate::model::badges::Badged;
use crate::model::edges::Related;
use crate::model::join::{AgentRef, BeadKey, Conflict};
use crate::model::tree::{self, Link};
use crate::model::types::{Edge, PaneKey, PaneStatus, Status, Unreadable};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AgentProvider {
pub provider: &'static str,
pub state: ProviderState,
pub sessions: Vec<Session>,
}
impl AgentProvider {
pub fn answering(provider: &'static str, sessions: Vec<Session>) -> Self {
Self {
provider,
state: ProviderState::Answering,
sessions,
}
}
pub fn unanswered(&self) -> impl Iterator<Item = &str> {
self.sessions
.iter()
.filter(|session| session.state == SessionState::NotAnswering)
.map(|session| session.name.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Session {
pub name: String,
pub state: SessionState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SessionState {
Answering,
NotAnswering,
}
#[cfg(feature = "testing")]
pub const A_PROVIDER: &str = "a provider";
#[cfg(feature = "testing")]
pub fn a_provider(state: ProviderState) -> AgentProvider {
AgentProvider {
provider: A_PROVIDER,
state,
sessions: Vec::new(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProviderState {
Answering,
NotAnswering,
Absent,
}
impl ProviderState {
pub fn answered(self) -> bool {
self == ProviderState::Answering
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Filter {
LiveAgents,
All,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "reason", rename_all = "kebab-case")]
pub enum TrackerFailure {
NoEnvironment,
NoCredential,
Auth,
Unavailable,
NotInstalled,
Unstartable,
InstalledUnstartable,
Parse(Unreadable),
UnknownFlag,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum TrackerState {
Ok,
Unreachable(TrackerFailure),
RootNotFound,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Readiness {
pub ready: BTreeSet<String>,
pub blocked_by: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Counts {
pub total: usize,
pub closed: usize,
pub live_agents: usize,
pub anomalies: usize,
}
impl Counts {
pub(crate) fn unfinished(&self) -> usize {
self.total - self.closed
}
pub fn over<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Self {
let mut counted = BTreeSet::new();
let once: Vec<&Node> = nodes
.into_iter()
.filter(|node| counted.insert(node.id.clone()))
.collect();
Counts {
total: once.len(),
closed: once.iter().filter(|n| n.status.is_closed()).count(),
live_agents: once.iter().filter(|n| n.agent.is_some()).count(),
anomalies: once.iter().filter(|n| !n.anomalies.is_empty()).count(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Node {
pub id: String,
pub title: String,
pub status: Status,
pub issue_type: String,
pub priority: u8,
pub ready: bool,
pub blocked_by: Vec<String>,
pub started_at: Option<DateTime<Utc>>,
pub closed_at: Option<DateTime<Utc>>,
pub badges: Vec<Badged>,
pub agent: Option<AgentRef>,
pub anomalies: Vec<Anomaly>,
#[serde(skip)]
pub description: String,
#[serde(skip)]
pub notes: String,
#[serde(skip)]
pub owner: Option<String>,
#[serde(skip)]
pub parent: Option<Related>,
#[serde(skip)]
pub depends_on: Vec<Related>,
#[serde(skip)]
pub blocks: Vec<Related>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tree {
pub project: String,
pub root: String,
pub title: String,
pub counts: Counts,
pub tracker: TrackerState,
pub beads: Vec<Node>,
pub children: Vec<Vec<Link>>,
pub dangling: Vec<String>,
pub cycles: Vec<String>,
}
impl Serialize for Tree {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut tree = serializer.serialize_struct("Tree", 8)?;
tree.serialize_field("project", &self.project)?;
tree.serialize_field("root", &self.root)?;
tree.serialize_field("title", &self.title)?;
tree.serialize_field("counts", &self.counts)?;
tree.serialize_field("tracker", &self.tracker)?;
tree.serialize_field("nodes", &self.unrolled())?;
tree.serialize_field("dangling", &self.dangling)?;
tree.serialize_field("cycles", &self.cycles)?;
tree.end()
}
}
#[derive(Serialize)]
struct Drawn<'a> {
id: &'a str,
title: &'a str,
status: &'a Status,
issue_type: &'a str,
priority: u8,
depth: u16,
edge: Option<Edge>,
ready: bool,
blocked_by: &'a [String],
started_at: Option<DateTime<Utc>>,
closed_at: Option<DateTime<Utc>>,
badges: &'a [Badged],
agent: Option<&'a AgentRef>,
anomalies: &'a [Anomaly],
}
impl Tree {
fn unrolled(&self) -> Vec<Drawn<'_>> {
tree::unroll(&self.children)
.into_iter()
.map(|placed| {
let node = &self.beads[placed.bead];
Drawn {
id: &node.id,
title: &node.title,
status: &node.status,
issue_type: &node.issue_type,
priority: node.priority,
depth: placed.depth,
edge: placed.edge,
ready: node.ready,
blocked_by: &node.blocked_by,
started_at: node.started_at,
closed_at: node.closed_at,
badges: &node.badges,
agent: node.agent.as_ref(),
anomalies: &node.anomalies,
}
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FailedProject {
pub project: String,
pub tracker: TrackerFailure,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Collected {
pub trees: Vec<Tree>,
pub failed_projects: Vec<FailedProject>,
pub read_at: BTreeMap<String, DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HiddenTree {
pub project: String,
pub root: String,
pub title: String,
pub reason: &'static str,
#[serde(skip)]
pub findings: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LoosePane {
pub pane: PaneKey,
pub project: String,
pub cwd: String,
pub pane_status: PaneStatus,
pub display_agent: Option<String>,
pub title: Option<String>,
pub claim_refused: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UnconfiguredPane {
pub pane: PaneKey,
pub cwd: String,
pub pane_status: PaneStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Snapshot {
pub generated_at: DateTime<Utc>,
pub agents: AgentProvider,
pub filter: Filter,
pub trees: Vec<Arc<Tree>>,
pub hidden_trees: Vec<HiddenTree>,
pub failed_projects: Vec<FailedProject>,
pub unattributed: Vec<LoosePane>,
pub unconfigured: Vec<UnconfiguredPane>,
pub conflicts: Vec<Conflict>,
pub projects_named_without_git: Vec<String>,
#[serde(skip)]
pub collected: Vec<Arc<Tree>>,
#[serde(skip)]
pub read_at: BTreeMap<String, DateTime<Utc>>,
#[serde(skip)]
pub projects: Vec<String>,
#[serde(skip)]
pub scope: Scope,
}
impl Snapshot {
pub fn awaiting(
projects: Vec<String>,
projects_named_without_git: Vec<String>,
provider: &'static str,
scope: Scope,
filter: Filter,
now: DateTime<Utc>,
) -> Self {
Snapshot {
generated_at: now,
agents: AgentProvider::answering(provider, Vec::new()),
filter,
trees: Vec::new(),
hidden_trees: Vec::new(),
failed_projects: Vec::new(),
unattributed: Vec::new(),
unconfigured: Vec::new(),
conflicts: Vec::new(),
projects_named_without_git,
collected: Vec::new(),
read_at: BTreeMap::new(),
projects,
scope,
}
}
pub fn every_root_read(&self, project: &str) -> bool {
self.trees
.iter()
.filter(|tree| tree.project == project)
.all(|tree| tree.tracker == TrackerState::Ok)
}
pub fn tree(&self, root: &BeadKey) -> Option<&Tree> {
self.collected
.iter()
.find(|tree| tree.project == root.project && tree.root == root.id)
.map(Arc::as_ref)
}
pub fn locate(&self, key: &BeadKey) -> Option<(&Tree, usize)> {
self.collected
.iter()
.filter(|tree| tree.project == key.project)
.find_map(|tree| {
let at = tree.beads.iter().position(|node| node.id == key.id)?;
Some((tree.as_ref(), at))
})
}
pub fn node(&self, key: &BeadKey) -> Option<&Node> {
self.locate(key).map(|(tree, at)| &tree.beads[at])
}
}
impl Tree {
#[cfg(test)]
pub fn tracker_unreachable(project: &str, root: &str, failure: TrackerFailure) -> Self {
Self::unread(project, root, TrackerState::Unreachable(failure))
}
pub fn unread(project: &str, root: &str, tracker: TrackerState) -> Self {
Self {
project: project.to_string(),
root: root.to_string(),
title: String::new(),
counts: Counts::default(),
tracker,
beads: Vec::new(),
children: Vec::new(),
dangling: Vec::new(),
cycles: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collect::bd::parse_beads;
use crate::collect::herdr::parse_agent_list;
use crate::config::Config;
use crate::model::edges;
use crate::model::join::{self, Joined, Listed, ProjectRows};
use crate::model::tree::{Assembled, Nesting};
use crate::model::types::testing::{an_unreadable, A_SESSION};
use crate::model::types::{Bead, Pane};
use pretty_assertions::assert_eq;
pub(super) const BEADS: &str = r#"[
{"id":"orb-7","title":"lift the ground station","status":"in_progress",
"priority":1,"issue_type":"epic","updated_at":"2026-08-29T12:00:00Z",
"started_at":"2026-08-20T09:00:00Z","metadata":{"agent_pane":"w:p1"}},
{"id":"orb-7.1","title":"re-point the dish","status":"open",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}],
"priority":2,"issue_type":"task",
"metadata":{"blocked_on":"human"}},
{"id":"orb-7.2","title":"survey the mast","status":"closed",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}],
"priority":2,"issue_type":"task","closed_at":"2026-08-28T09:00:00Z"},
{"id":"orb-7.3","title":"lay the feeder cable","status":"in_progress",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}],
"priority":1,"issue_type":"task","updated_at":"2026-07-01T12:00:00Z"},
{"id":"orb-7.4","title":"file the licence","status":"open",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}],
"priority":3,"issue_type":"chore"}
]"#;
pub(super) const PANES: &str = r#"{"result":{"agents":[
{"pane_id":"w:p1","cwd":"/srv/work/orbital","agent_status":"working",
"state_labels":{"working":"lifting the mast"}},
{"pane_id":"w:p2","cwd":"/srv/work/orbital","agent_status":"idle",
"display_agent":"orb-7.2","title":"survey"},
{"pane_id":"w:p9","cwd":"/srv/work/orbital","agent_status":"blocked"},
{"pane_id":"w:pF","cwd":"/srv/spike","agent_status":"idle"}
]}}"#;
pub(super) fn cfg() -> Config {
Config::from_toml(
r#"
[[projects]]
name = "orbital"
path = "/srv/work/orbital"
credential_command = "secret orbital"
[[projects]]
name = "ferry"
path = "/srv/work/ferry"
credential_command = "secret ferry"
[[badges]]
key = "blocked_on"
match = "human"
render = "⏸ waiting"
"#,
)
.expect("the config parses")
}
pub(super) fn now() -> DateTime<Utc> {
"2026-08-30T12:00:00Z".parse().expect("the instant parses")
}
fn root_row(beads: &[Bead]) -> String {
beads
.iter()
.find(|b| b.dependencies.is_empty())
.expect("a root row")
.id
.clone()
}
pub(super) fn assembled(json: &str) -> Assembled {
let beads = parse_beads(json).expect("the rows parse");
let root = root_row(&beads);
Nesting::of(&beads)
.assemble(&root)
.expect("the rows assemble")
}
pub(super) fn panes(json: &str) -> Vec<Pane> {
parse_agent_list(A_SESSION, json).expect("the panes parse")
}
pub(super) fn joined(rows: &[Bead], panes: &[Pane]) -> Joined {
let cfg = cfg();
join::resolve(
&[ProjectRows {
project: "orbital",
rows,
}],
Listed::all(panes),
&cfg,
)
}
pub(super) fn readiness() -> Readiness {
Readiness {
ready: BTreeSet::from(["orb-7.4".to_string()]),
blocked_by: BTreeMap::from([(
"orb-7.1".to_string(),
vec!["orb-9".to_string(), "orb-7.3".to_string()],
)]),
}
}
pub(super) fn tree() -> Tree {
let assembled = assembled(BEADS);
let panes = panes(PANES);
let joined = joined(&assembled.beads, &panes);
let relations = edges::relations(&assembled.beads);
build_tree(
"orbital",
&assembled,
&joined,
&readiness(),
&relations,
ProviderState::Answering,
&cfg(),
now(),
)
}
pub(super) fn built(trees: Vec<Tree>, filter: Filter) -> Snapshot {
let panes = panes(PANES);
let joined = joined(&assembled(BEADS).beads, &panes);
build(
Collected {
trees,
..Collected::default()
},
&panes,
&joined,
&cfg(),
a_provider(ProviderState::Answering),
filter,
now(),
)
}
pub(super) fn snapshot(trees: Vec<Tree>) -> Snapshot {
built(trees, Filter::LiveAgents)
}
#[test]
fn the_snapshot_serialises() {
let snap = snapshot(vec![tree()]);
let json: serde_json::Value = serde_json::to_value(&snap).expect("the snapshot serialises");
assert_eq!(json["generated_at"], "2026-08-30T12:00:00Z");
assert_eq!(json["agents"]["provider"], A_PROVIDER);
assert_eq!(json["agents"]["state"], "answering");
assert_eq!(json["filter"], "live-agents");
assert!(
json.get("scope").is_none(),
"which projects a run read is in its trees; the scope is the view's"
);
assert_eq!(json["trees"][0]["tracker"], "ok");
assert_eq!(json["trees"][0]["counts"]["total"], 5);
assert_eq!(json["trees"][0]["nodes"][0]["id"], "orb-7");
assert_eq!(
json["trees"][0]["nodes"][0]["agent"]["source"],
"agent_pane"
);
assert_eq!(json["unattributed"][0]["project"], "orbital");
assert!(
json["trees"][0]["nodes"][1]["anomalies"].is_array(),
"anomalies is a list"
);
}
#[test]
fn a_row_of_nodes_carries_every_field_of_its_bead_and_its_place() {
let t = tree();
let json = serde_json::to_value(&t).expect("the tree serialises");
let bead = serde_json::to_value(&t.beads[1]).expect("the bead serialises");
let bead = bead.as_object().expect("a bead is an object");
let row = json["nodes"][1].as_object().expect("a row is an object");
let mut expected: BTreeSet<&str> = bead.keys().map(String::as_str).collect();
expected.extend(["depth", "edge"]);
assert_eq!(
row.keys().map(String::as_str).collect::<BTreeSet<_>>(),
expected
);
for (key, value) in bead {
assert_eq!(&row[key], value, "{key}");
}
}
#[test]
fn a_row_of_nodes_puts_its_place_after_the_beads_priority() {
let written = serde_json::to_string(&tree()).expect("the tree serialises");
assert!(
written.contains(r#""priority":1,"depth":1,"edge":"parent-child","ready":false"#),
"{written}"
);
}
#[test]
fn an_unreachable_tracker_serialises_with_its_reason() {
let snap = build(
Collected {
trees: vec![Tree::tracker_unreachable(
"ferry",
"fry-3",
TrackerFailure::Auth,
)],
failed_projects: vec![FailedProject {
project: "orbital".to_string(),
tracker: TrackerFailure::Parse(an_unreadable()),
}],
..Default::default()
},
&[],
&Joined::default(),
&cfg(),
a_provider(ProviderState::Answering),
Filter::LiveAgents,
now(),
);
let json: serde_json::Value = serde_json::to_value(&snap).expect("the snapshot serialises");
assert_eq!(json["trees"][0]["tracker"]["unreachable"]["reason"], "auth");
assert_eq!(json["trees"][0]["counts"]["total"], 0);
let tracker = &json["failed_projects"][0]["tracker"];
assert_eq!(tracker["reason"], "parse");
assert_eq!(tracker["read"], "list");
assert_eq!(
tracker["cause"],
"invalid type: null, expected a string at line 1 column 25"
);
}
#[test]
fn a_failure_that_knows_only_its_kind_writes_only_its_kind() {
let snap = build(
Collected {
failed_projects: vec![FailedProject {
project: "orbital".to_string(),
tracker: TrackerFailure::Auth,
}],
..Default::default()
},
&[],
&Joined::default(),
&cfg(),
a_provider(ProviderState::Answering),
Filter::LiveAgents,
now(),
);
let json: serde_json::Value = serde_json::to_value(&snap).expect("the snapshot serialises");
let tracker = &json["failed_projects"][0]["tracker"];
assert_eq!(tracker["reason"], "auth");
assert_eq!(tracker.get("read"), None);
assert_eq!(tracker.get("cause"), None);
}
#[test]
fn a_root_the_tracker_does_not_hold_serialises_as_such() {
let snap = build(
Collected {
trees: vec![Tree::unread("ferry", "fry-3", TrackerState::RootNotFound)],
..Default::default()
},
&[],
&Joined::default(),
&cfg(),
a_provider(ProviderState::Answering),
Filter::All,
now(),
);
let json: serde_json::Value = serde_json::to_value(&snap).expect("the snapshot serialises");
assert_eq!(json["trees"][0]["tracker"], "root-not-found");
assert!(json["failed_projects"]
.as_array()
.is_some_and(Vec::is_empty));
}
fn ferry() -> Tree {
let json = r#"[
{"id":"orb-7","title":"berth the ferry","status":"open"},
{"id":"orb-7.1","title":"paint the hull","status":"open",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}]},
{"id":"frr-1","title":"lift the ramp","status":"open",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}]}
]"#;
build_tree(
"ferry",
&assembled(json),
&Joined::default(),
&Readiness::default(),
&BTreeMap::new(),
ProviderState::Answering,
&cfg(),
now(),
)
}
fn key(project: &str, id: &str) -> BeadKey {
BeadKey {
project: project.to_string(),
id: id.to_string(),
}
}
#[test]
fn one_id_in_two_trackers_answers_for_each_project_separately() {
let snap = built(vec![tree(), ferry()], Filter::All);
assert_eq!(
snap.node(&key("orbital", "orb-7.1"))
.map(|n| n.title.as_str()),
Some("re-point the dish")
);
assert_eq!(
snap.node(&key("ferry", "orb-7.1"))
.map(|n| n.title.as_str()),
Some("paint the hull")
);
assert_eq!(
snap.locate(&key("ferry", "orb-7.1"))
.map(|(t, _)| t.project.as_str()),
Some("ferry")
);
}
#[test]
fn a_bead_in_a_hidden_tree_is_still_named_by_its_key() {
let snap = built(vec![tree(), ferry()], Filter::LiveAgents);
assert_eq!(snap.hidden_trees.len(), 1, "{:?}", snap.hidden_trees);
let hidden = key("ferry", &snap.hidden_trees[0].root);
assert_eq!(
snap.node(&hidden).map(|n| n.title.as_str()),
Some("berth the ferry")
);
assert_eq!(
snap.node(&key("ferry", "orb-7.1"))
.map(|n| n.title.as_str()),
Some("paint the hull")
);
assert_eq!(
snap.tree(&hidden).map(|t| t.title.as_str()),
Some("berth the ferry")
);
assert_eq!(
snap.tree(&key("ferry", "orb-7.1")),
None,
"a bead that is not a root names no tree"
);
}
#[test]
fn a_bead_in_one_project_is_not_found_through_another_projects_key() {
let snap = built(vec![tree(), ferry()], Filter::All);
assert_eq!(snap.node(&key("ferry", "orb-7.4")), None);
assert_eq!(snap.node(&key("orbital", "frr-1")), None);
}
}