use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::error::Result;
use crate::model::RevisionId;
use crate::session::event::{EventType, ShoreEvent, WorkObjectProposal, WorkObjectProposedPayload};
use crate::session::state::ProjectionDiagnostic;
pub const SUPERSESSION_TARGET_MISSING_CODE: &str = "supersession_target_missing";
pub const SUPERSESSION_CYCLE_CODE: &str = "supersession_cycle";
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SupersessionView {
pub heads: BTreeSet<RevisionId>,
pub superseded: BTreeSet<RevisionId>,
pub supersedes: BTreeMap<RevisionId, BTreeSet<RevisionId>>,
pub superseded_by: BTreeMap<RevisionId, BTreeSet<RevisionId>>,
pub components: Vec<BTreeSet<RevisionId>>,
pub diagnostics: Vec<ProjectionDiagnostic>,
}
impl SupersessionView {
pub fn from_edges(edges: impl IntoIterator<Item = (RevisionId, Vec<RevisionId>)>) -> Self {
let mut declared: BTreeMap<RevisionId, BTreeSet<RevisionId>> = BTreeMap::new();
for (revision, targets) in edges {
declared.entry(revision).or_default().extend(targets);
}
let known: BTreeSet<RevisionId> = declared.keys().cloned().collect();
let mut superseded: BTreeSet<RevisionId> = BTreeSet::new();
let mut supersedes: BTreeMap<RevisionId, BTreeSet<RevisionId>> = BTreeMap::new();
let mut superseded_by: BTreeMap<RevisionId, BTreeSet<RevisionId>> = BTreeMap::new();
let mut diagnostics: Vec<ProjectionDiagnostic> = Vec::new();
for (revision, targets) in &declared {
if !targets.is_empty() {
supersedes.insert(revision.clone(), targets.clone());
}
for target in targets {
if known.contains(target) {
superseded.insert(target.clone());
superseded_by
.entry(target.clone())
.or_default()
.insert(revision.clone());
} else {
diagnostics.push(ProjectionDiagnostic {
code: SUPERSESSION_TARGET_MISSING_CODE.to_owned(),
message: format!(
"revision {} supersedes unknown revision {}",
revision.as_str(),
target.as_str()
),
});
}
}
}
let heads: BTreeSet<RevisionId> = known.difference(&superseded).cloned().collect();
let components = connected_components(&known, &declared);
for cycle in directed_cycles(&known, &declared) {
let members = cycle
.iter()
.map(RevisionId::as_str)
.collect::<Vec<_>>()
.join(", ");
diagnostics.push(ProjectionDiagnostic {
code: SUPERSESSION_CYCLE_CODE.to_owned(),
message: format!("revisions form a supersession cycle: {members}"),
});
}
Self {
heads,
superseded,
supersedes,
superseded_by,
components,
diagnostics,
}
}
pub fn from_events(events: &[ShoreEvent]) -> Result<Self> {
let mut edges: Vec<(RevisionId, Vec<RevisionId>)> = Vec::new();
for event in events
.iter()
.filter(|event| event.event_type == EventType::WorkObjectProposed)
{
let payload: WorkObjectProposedPayload = serde_json::from_value(event.payload.clone())?;
if let WorkObjectProposal::Revision {
revision,
supersedes,
..
} = payload.work_object
{
edges.push((revision.id, supersedes));
}
}
Ok(Self::from_edges(edges))
}
pub fn component_of(&self, revision: &RevisionId) -> Option<&BTreeSet<RevisionId>> {
self.components
.iter()
.find(|component| component.contains(revision))
}
pub fn heads_for(&self, revision: &RevisionId) -> BTreeSet<RevisionId> {
match self.component_of(revision) {
Some(component) => component.intersection(&self.heads).cloned().collect(),
None => BTreeSet::new(),
}
}
pub fn stale_by_superseding_revision(&self, revision: &RevisionId) -> BTreeSet<RevisionId> {
self.superseded_by
.get(revision)
.cloned()
.unwrap_or_default()
}
}
fn connected_components(
known: &BTreeSet<RevisionId>,
declared: &BTreeMap<RevisionId, BTreeSet<RevisionId>>,
) -> Vec<BTreeSet<RevisionId>> {
let nodes: Vec<&RevisionId> = known.iter().collect();
let index_of: BTreeMap<&RevisionId, usize> =
nodes.iter().enumerate().map(|(i, r)| (*r, i)).collect();
let mut parent: Vec<usize> = (0..nodes.len()).collect();
for (revision, targets) in declared {
let Some(&a) = index_of.get(revision) else {
continue;
};
for target in targets {
if let Some(&b) = index_of.get(target) {
let ra = find(&mut parent, a);
let rb = find(&mut parent, b);
if ra != rb {
parent[ra] = rb;
}
}
}
}
let mut groups: BTreeMap<usize, BTreeSet<RevisionId>> = BTreeMap::new();
for (i, revision) in nodes.iter().enumerate() {
let root = find(&mut parent, i);
groups.entry(root).or_default().insert((*revision).clone());
}
groups.into_values().collect()
}
fn find(parent: &mut [usize], mut node: usize) -> usize {
while parent[node] != node {
parent[node] = parent[parent[node]];
node = parent[node];
}
node
}
fn directed_cycles(
known: &BTreeSet<RevisionId>,
declared: &BTreeMap<RevisionId, BTreeSet<RevisionId>>,
) -> Vec<BTreeSet<RevisionId>> {
let nodes: Vec<&RevisionId> = known.iter().collect();
let index_of: BTreeMap<&RevisionId, usize> =
nodes.iter().enumerate().map(|(i, r)| (*r, i)).collect();
let n = nodes.len();
let adjacency: Vec<Vec<usize>> = nodes
.iter()
.map(|revision| {
declared
.get(*revision)
.into_iter()
.flatten()
.filter_map(|target| index_of.get(target).copied())
.collect()
})
.collect();
const UNVISITED: usize = usize::MAX;
let mut index = vec![UNVISITED; n];
let mut lowlink = vec![0usize; n];
let mut on_stack = vec![false; n];
let mut stack: Vec<usize> = Vec::new();
let mut next_index = 0usize;
let mut sccs: Vec<Vec<usize>> = Vec::new();
for start in 0..n {
if index[start] != UNVISITED {
continue;
}
let mut call_stack: Vec<(usize, usize)> = vec![(start, 0)];
while let Some(&(node, cursor)) = call_stack.last() {
if cursor == 0 {
index[node] = next_index;
lowlink[node] = next_index;
next_index += 1;
stack.push(node);
on_stack[node] = true;
}
if cursor < adjacency[node].len() {
let child = adjacency[node][cursor];
call_stack.last_mut().unwrap().1 += 1;
if index[child] == UNVISITED {
call_stack.push((child, 0));
} else if on_stack[child] {
lowlink[node] = lowlink[node].min(index[child]);
}
} else {
if lowlink[node] == index[node] {
let mut scc = Vec::new();
loop {
let popped = stack.pop().expect("tarjan stack non-empty at scc root");
on_stack[popped] = false;
scc.push(popped);
if popped == node {
break;
}
}
sccs.push(scc);
}
call_stack.pop();
if let Some(&(parent, _)) = call_stack.last() {
lowlink[parent] = lowlink[parent].min(lowlink[node]);
}
}
}
}
sccs.into_iter()
.filter(|scc| scc.len() > 1 || (scc.len() == 1 && adjacency[scc[0]].contains(&scc[0])))
.map(|scc| scc.into_iter().map(|i| nodes[i].clone()).collect())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn rev(suffix: &str) -> RevisionId {
RevisionId::new(format!("rev:sha256:{suffix}"))
}
fn set<const N: usize>(items: [RevisionId; N]) -> BTreeSet<RevisionId> {
items.into_iter().collect()
}
#[test]
fn current_head_is_the_revision_not_in_superseded() {
let v = SupersessionView::from_edges([(rev("A"), vec![]), (rev("B"), vec![rev("A")])]);
assert_eq!(v.heads, set([rev("B")]));
assert_eq!(v.superseded, set([rev("A")]));
assert!(v.diagnostics.is_empty());
}
#[test]
fn fork_surfaces_competing_heads_never_nulls() {
let v = SupersessionView::from_edges([
(rev("A"), vec![]),
(rev("B"), vec![rev("A")]),
(rev("C"), vec![rev("A")]),
]);
assert_eq!(v.heads, set([rev("B"), rev("C")]));
assert!(v.diagnostics.is_empty());
}
#[test]
fn superseded_by_names_all_direct_superseders() {
let v = SupersessionView::from_edges([
(rev("A"), vec![]),
(rev("B"), vec![rev("A")]),
(rev("C"), vec![rev("A")]),
]);
assert_eq!(
v.superseded_by.get(&rev("A")),
Some(&set([rev("B"), rev("C")]))
);
}
#[test]
fn supersedes_is_set_deduped_per_revision() {
let v = SupersessionView::from_edges([
(rev("A"), vec![]),
(rev("B"), vec![]),
(rev("C"), vec![rev("A"), rev("B"), rev("A")]),
]);
assert_eq!(
v.supersedes.get(&rev("C")),
Some(&set([rev("A"), rev("B")]))
);
}
#[test]
fn cycle_yields_a_diagnostic_affecting_only_the_cycle() {
let v =
SupersessionView::from_edges([(rev("A"), vec![rev("B")]), (rev("B"), vec![rev("A")])]);
let cycle = v
.diagnostics
.iter()
.find(|d| d.code == SUPERSESSION_CYCLE_CODE)
.expect("cycle diagnostic");
assert!(cycle.message.contains("rev:sha256:A"));
assert!(cycle.message.contains("rev:sha256:B"));
}
#[test]
fn dangling_supersedes_self_heals_and_never_rejects() {
let v = SupersessionView::from_edges([(rev("B"), vec![rev("X")])]);
assert!(
v.diagnostics
.iter()
.any(|d| d.code == SUPERSESSION_TARGET_MISSING_CODE)
);
assert!(v.heads.contains(&rev("B")));
}
#[test]
fn dangling_target_self_heals_when_it_backfills() {
let v = SupersessionView::from_edges([(rev("X"), vec![]), (rev("B"), vec![rev("X")])]);
assert!(v.diagnostics.is_empty());
assert_eq!(v.superseded, set([rev("X")]));
assert_eq!(v.heads, set([rev("B")]));
}
#[test]
fn heads_for_is_thread_scoped_across_unrelated_components() {
let v = SupersessionView::from_edges([
(rev("A"), vec![]),
(rev("B"), vec![rev("A")]),
(rev("C"), vec![rev("A")]),
(rev("Z"), vec![]),
]);
assert_eq!(v.heads, set([rev("B"), rev("C"), rev("Z")])); assert_eq!(v.heads_for(&rev("A")), set([rev("B"), rev("C")])); assert_eq!(v.heads_for(&rev("B")), set([rev("B"), rev("C")])); assert_eq!(v.heads_for(&rev("Z")), set([rev("Z")])); assert_eq!(v.components.len(), 2);
}
#[test]
fn component_of_is_none_for_an_unknown_revision() {
let v = SupersessionView::from_edges([(rev("A"), vec![])]);
assert!(v.component_of(&rev("missing")).is_none());
assert!(v.heads_for(&rev("missing")).is_empty());
}
#[test]
fn stale_by_superseding_revision_names_all_successors() {
let v = SupersessionView::from_edges([
(rev("A"), vec![]),
(rev("B"), vec![rev("A")]),
(rev("C"), vec![rev("A")]),
]);
assert_eq!(
v.stale_by_superseding_revision(&rev("A")),
set([rev("B"), rev("C")])
);
assert!(v.stale_by_superseding_revision(&rev("B")).is_empty());
}
mod from_events {
use super::super::*;
use super::rev;
use crate::model::{
EngagementId, JournalId, ObjectId, ReviewEndpoint, RevisionSource, TargetRef,
TaskTargetRef, WorkObjectId, WorktreeCaptureMode,
};
use crate::session::event::{EventTarget, GitProvenance, Revision, Writer};
fn revision_event(suffix: &str, supersedes: Vec<RevisionId>) -> ShoreEvent {
let revision_id = rev(suffix);
ShoreEvent::new(
EventType::WorkObjectProposed,
format!("work_object_proposed:{}", revision_id.as_str()),
EventTarget::for_revision(
JournalId::new("journal:default"),
revision_id.clone(),
None,
)
.unwrap(),
Writer::shore_local("test"),
WorkObjectProposedPayload {
engagement_id: EngagementId::new(format!("engagement:sha256:{suffix}")),
work_object: WorkObjectProposal::Revision {
revision: Revision {
id: revision_id,
object_id: ObjectId::new(format!("obj:sha256:{suffix}")),
git_provenance: Some(GitProvenance {
source: RevisionSource::GitWorktree {
mode: WorktreeCaptureMode::CombinedHeadToWorkingTree,
include_untracked: true,
pathspecs: Vec::new(),
},
base: ReviewEndpoint::GitCommit {
commit_oid: "base".to_owned(),
tree_oid: "base-tree".to_owned(),
},
target: ReviewEndpoint::GitWorkingTree {
worktree_root: "/repo".to_owned(),
},
}),
},
object_artifact_content_hash: format!("sha256:artifact:{suffix}"),
supersedes,
},
},
"2026-06-04T00:00:00Z",
)
.unwrap()
}
fn task_attempt_event(suffix: &str) -> ShoreEvent {
ShoreEvent::new(
EventType::WorkObjectProposed,
format!("task-capture:{suffix}"),
EventTarget::for_subject(
JournalId::new("journal:default"),
TargetRef::Task(TaskTargetRef::TaskAttempt {
task_attempt_id: WorkObjectId::new(format!("task-attempt:sha256:{suffix}")),
}),
None,
)
.unwrap(),
Writer::shore_local("test"),
WorkObjectProposedPayload {
engagement_id: EngagementId::new(format!("engagement:sha256:{suffix}")),
work_object: WorkObjectProposal::TaskAttempt {
task_attempt_id: WorkObjectId::new(format!("task-attempt:sha256:{suffix}")),
project_path: "/repo".to_owned(),
claude_session_uuid: format!("uuid-{suffix}"),
initial_prompt_hash: format!("sha256:prompt:{suffix}"),
predecessor: None,
base_state_fingerprint: None,
source_speaker: None,
},
},
"2026-06-04T00:00:01Z",
)
.unwrap()
}
#[test]
fn reads_supersedes_into_competing_heads_skipping_the_task_arm() {
let events = vec![
revision_event("a", vec![]),
revision_event("b", vec![rev("a")]),
revision_event("c", vec![rev("a")]),
task_attempt_event("t"),
];
let view = SupersessionView::from_events(&events).unwrap();
assert_eq!(view.heads, [rev("b"), rev("c")].into_iter().collect());
assert_eq!(view.superseded, [rev("a")].into_iter().collect());
assert!(view.diagnostics.is_empty());
}
}
}