Improve behavior authoring and typed trace history (untested checkpoint)
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s

This commit is contained in:
Codex
2026-09-10 11:07:07 -07:00
parent 1797516f46
commit 01bb52a76d
25 changed files with 4078 additions and 171 deletions
File diff suppressed because it is too large Load Diff
+59 -4
View File
@@ -836,6 +836,12 @@ struct GraphEdge {
incidence: bool,
layer: EdgeLayer,
}
// Ordered exchange adjacency is a projection, never a persisted relation ID.
struct OrderedPath {
source: Id,
target: Id,
points: Vec<Point>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EdgeLayer {
Direct,
@@ -863,6 +869,8 @@ struct Graph<'a> {
scroll_offset: iced::Vector,
nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
ordered_paths: Vec<OrderedPath>,
ordered: bool,
size: Size,
selected: Id,
scale: f32,
@@ -884,11 +892,17 @@ impl<'a> Graph<'a> {
let context_index = oa_core::topology_context::TopologyIndex::new(p);
let choices = contributions(p, owner);
let links = sequence_links(p, owner);
let order = legacy_order(p, owner);
let adjacency: Vec<_> = links
.iter()
.map(|r| (r.source(), r.target()))
.chain(order.windows(2).map(|pair| (pair[0], pair[1])))
.collect();
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
for r in &links {
*incoming.entry(r.target()).or_default() += 1;
for &(_, target) in &adjacency {
*incoming.entry(target).or_default() += 1;
}
let mut queue: Vec<_> = choices
.iter()
@@ -903,8 +917,7 @@ impl<'a> Graph<'a> {
if !done.insert(id) {
continue;
}
for r in links.iter().filter(|r| r.source() == id) {
let next = r.target();
for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
let count = incoming.get_mut(&next).expect("Graph endpoint");
*count = count.saturating_sub(1);
@@ -1031,6 +1044,29 @@ impl<'a> Graph<'a> {
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
nodes.push(node);
}
let ordered_paths = order
.windows(2)
.map(|pair| {
let a = nodes
.iter()
.find(|n| n.id == pair[0])
.expect("ordered source")
.rect;
let b = nodes
.iter()
.find(|n| n.id == pair[1])
.expect("ordered target")
.rect;
OrderedPath {
source: pair[0],
target: pair[1],
points: vec![
Point::new(a.x + a.width, a.center_y()),
Point::new(b.x, b.center_y()),
],
}
})
.collect();
let mut edges = vec![];
let mut routed_pairs = BTreeSet::new();
for (i, r) in links.into_iter().enumerate() {
@@ -1165,6 +1201,8 @@ impl<'a> Graph<'a> {
scroll_offset: iced::Vector::ZERO,
nodes,
edges,
ordered_paths,
ordered: !order.is_empty(),
size,
selected,
scale: 1.,
@@ -1264,6 +1302,18 @@ impl canvas::Program<Message> for Graph<'_> {
for pass in PAINT_PASSES {
match pass {
PaintPass::DirectEdges | PaintPass::ContextualEdges => {
if pass == PaintPass::DirectEdges {
for path in &self.ordered_paths {
let selected =
path.source == self.selected || path.target == self.selected;
draw_arrow(
&mut f,
&path.points,
if selected { style::TEAL } else { style::MUTED },
if selected { 2.5 } else { 1.5 },
);
}
}
for e in self.edges.iter().filter(|edge| {
edge.layer
== if pass == PaintPass::DirectEdges {
@@ -1465,6 +1515,11 @@ impl canvas::Program<Message> for Graph<'_> {
if cursor.position_in(bounds).is_some_and(|p| {
let p = Point::new(p.x / self.scale, p.y / self.scale);
self.nodes.iter().any(|n| n.rect.contains(p))
|| self.ordered_paths.iter().any(|path| {
path.points.windows(2).any(|pair| {
crate::diagram::distance_to_segment(p, pair[0], pair[1]) < 8. / self.scale
})
})
|| self.edges.iter().any(|e| {
e.label.contains(p)
|| e.points
@@ -238,9 +238,7 @@ impl State {
) {
return Err("Choose a contribution tool.".into());
}
if !legacy_order(p, context.owner).is_empty() {
return Err("Convert the ordered path to explicit links before drawing.".into());
}
let order = legacy_order(p, context.owner);
let link = insertion
.link
.map(|id| {
@@ -268,6 +266,13 @@ impl State {
if let Some(r) = link {
d.before = Some(r.source());
d.after = Some(r.target());
} else if !order.is_empty() {
let index = insertion
.before
.and_then(|id| order.iter().position(|v| *v == id))
.unwrap_or(order.len());
d.before = index.checked_sub(1).map(|i| order[i]);
d.after = order.get(index).copied();
}
Ok(())
}
@@ -416,6 +421,13 @@ impl Input {
}
}
impl Graph<'_> {
fn ordered_path_at(&self, point: Point) -> Option<&OrderedPath> {
self.ordered_paths.iter().find(|path| {
path.points.windows(2).any(|pair| {
crate::diagram::distance_to_segment(point, pair[0], pair[1]) < 8. / self.scale
})
})
}
fn node_at(&self, point: Point) -> Option<Id> {
self.nodes
.iter()
@@ -579,7 +591,9 @@ impl Graph<'_> {
link: self
.edge_at(point)
.filter(|_| self.node_at(point).is_none()),
before: self.node_at(point),
before: self.node_at(point).or_else(|| {
self.ordered_path_at(point).map(|path| path.target)
}),
},
});
}
@@ -807,6 +821,13 @@ impl Graph<'_> {
draw_arrow(f, &e.points, style::TEAL, 3.);
}
"Insert on this path · preserve its guard"
} else if let Some(path) = self.ordered_path_at(point) {
draw_arrow(f, &path.points, style::TEAL, 3.);
"Insert on ordered path · preserve its order"
} else if self.ordered && self.node_at(point).is_some() {
"Insert before this contribution · preserve path"
} else if self.ordered {
"Continue the ordered path"
} else if self.node_at(point).is_some() {
"Add before this contribution · unconnected"
} else {
@@ -970,6 +970,359 @@ fn direct_process_append_preserves_existing_order_and_rejects_incompatible_excha
}
}
fn assert_interactive_owner_view(p: &Project, owner: Id) {
use iced::advanced::widget::Tree;
fn contains(tree: &Tree, tag: iced::advanced::widget::tree::Tag) -> bool {
tree.tag == tag || tree.children.iter().any(|child| contains(child, tag))
}
let mut app = App::with_project(p.clone());
app.layer = p.elements[&owner].layer();
app.page = crate::app::Page::Behavior;
app.selected = owner;
app.behavior.active_owner = Some(owner);
let graph: Element<'_, Message> = canvas::Canvas::new(Graph::new(p, owner, owner)).into();
let tag = Tree::new(graph.as_widget()).tag;
let view = view::view(&app, owner, Column::new());
assert!(
contains(&Tree::new(view.as_widget()), tag),
"every path uses the interactive graph"
);
}
#[test]
fn direct_process_three_ordered_exchanges_preserve_outer_paths_on_insertion() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut f, next) = empty_exchange_fixture(layer);
f.p = execute_batch(
&f.p,
&[Command::Connect(
f.p.relations[&f.exchange].kind(),
"Return for reassessment".into(),
f.work[2],
f.work[0],
)],
)
.unwrap();
let last =
f.p.relations
.values()
.find(|r| r.name == "Return for reassessment")
.unwrap()
.id;
for exchange in [f.exchange, next, last] {
let node = occurrence(
&f.p,
f.owner,
"Ordered contribution",
OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
}
for index in [0, 2] {
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let before = doc.bundle().clone();
let old_order = legacy_order(doc.project(), f.owner);
let graph = Graph::new(doc.project(), f.owner, f.owner);
assert_eq!(graph.ordered_paths.len(), 2);
assert!(graph.edges.is_empty());
let mut state = State::default();
state
.begin(
doc.project(),
context(f.owner),
Tool::Work,
ControlKind::Choice,
Insertion {
link: None,
before: Some(old_order[index]),
},
)
.unwrap();
state
.update(
doc.project(),
Event::Target(Choice {
id: f.work[index],
label: "Shared activity".into(),
}),
)
.unwrap();
doc.execute(state.prepare_pending(doc.project()).unwrap())
.unwrap();
let added = created(&f.p, doc.project(), f.owner);
let mut expected = old_order;
expected.insert(index, added);
assert_eq!(doc.project().elements[&f.owner].steps().unwrap(), &expected);
let links = sequence_links(doc.project(), f.owner);
assert_eq!(links.len(), 3);
for pair in expected.windows(2) {
assert_eq!(
links
.iter()
.filter(|r| r.source() == pair[0] && r.target() == pair[1])
.count(),
1
);
}
for (id, relation) in &f.p.relations {
assert_eq!(&doc.project().relations[id], relation);
}
let after = doc.bundle().clone();
assert!(doc.undo());
assert_eq!(doc.bundle(), &before);
assert_eq!(doc.project(), &f.p);
assert!(doc.redo());
assert_eq!(doc.bundle(), &after);
}
}
}
#[test]
fn direct_process_incompatible_imported_order_rejects_conversion_without_losing_draft() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut f, _) = empty_exchange_fixture(layer);
// Repeated exchange uses have different occurrences, but this legacy path
// has an endpoint gap. Converting its implied adjacency must reject.
for _ in 0..2 {
let node = occurrence(
&f.p,
f.owner,
"Repeated exchange",
OccurrenceBody::Exchange {
operation: None,
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
}
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let before = doc.bundle().clone();
assert_interactive_owner_view(doc.project(), f.owner);
assert_eq!(
Graph::new(doc.project(), f.owner, f.owner)
.ordered_paths
.len(),
1
);
let mut state = State::default();
state
.begin(
doc.project(),
context(f.owner),
Tool::Control,
ControlKind::Choice,
Insertion::default(),
)
.unwrap();
state
.update(
doc.project(),
Event::Name("Retain this proposed decision".into()),
)
.unwrap();
assert!(state.prepare_pending(doc.project()).is_err());
assert_eq!(
state.draft.as_ref().unwrap().name,
"Retain this proposed decision"
);
assert_eq!(doc.bundle(), &before);
assert_eq!(doc.project(), &f.p);
assert!(!doc.undo(), "rejection must not create history");
state.update(doc.project(), Event::Cancel).unwrap();
assert!(state.draft.is_none());
assert_eq!(doc.bundle(), &before);
}
}
#[test]
fn direct_process_empty_first_and_second_exchange_keep_the_same_interactive_renderer() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = empty_exchange_fixture(layer);
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
for (index, target) in [f.exchange, next].into_iter().enumerate() {
assert_interactive_owner_view(doc.project(), f.owner);
let before = doc.bundle().clone();
let mut graph = Graph::new(doc.project(), f.owner, f.owner);
let c = context(f.owner);
graph.interaction = Some((
c,
CanvasTool::Add(Tool::Exchange, ControlKind::Choice),
true,
));
let mut input = Input::default();
let bounds = Rectangle::with_size(graph.size);
let point = Point::new(graph.size.width - 10., graph.size.height - 10.);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
bounds,
);
let Some(Message::TopologyCanvas(CanvasEvent::Begin { insertion, .. })) = send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
point,
bounds,
) else {
panic!("canvas retains direct exchange insertion");
};
let mut state = State::default();
state
.begin(
doc.project(),
c,
Tool::Exchange,
ControlKind::Choice,
insertion,
)
.unwrap();
state
.update(
doc.project(),
Event::Target(Choice {
id: target,
label: "Exchange".into(),
}),
)
.unwrap();
doc.execute(state.prepare_pending(doc.project()).unwrap())
.unwrap();
assert_eq!(
doc.project().elements[&f.owner].steps().unwrap().len(),
index + 1
);
assert_eq!(sequence_links(doc.project(), f.owner).len(), index);
assert_interactive_owner_view(doc.project(), f.owner);
let after = doc.bundle().clone();
assert!(doc.undo());
assert_eq!(doc.bundle(), &before);
assert!(doc.redo());
assert_eq!(doc.bundle(), &after);
}
}
}
#[test]
fn direct_process_imported_order_is_projected_without_mutation_and_first_canvas_edit_is_atomic() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut f, next) = empty_exchange_fixture(layer);
for exchange in [f.exchange, next] {
let node = occurrence(
&f.p,
f.owner,
"Imported exchange",
OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
}
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let before = doc.bundle().clone();
let order = legacy_order(doc.project(), f.owner);
assert_eq!(order.len(), 2);
assert_interactive_owner_view(doc.project(), f.owner);
let mut graph = Graph::new(doc.project(), f.owner, order[0]);
assert!(
graph.edges.is_empty(),
"projection never invents relation identities"
);
assert_eq!(graph.ordered_paths.len(), 1);
assert!(position(&graph, order[0]).x < position(&graph, order[1]).x);
let points = &graph.ordered_paths[0].points;
let point = Point::new((points[0].x + points[1].x) / 2., points[0].y);
let c = context(f.owner);
graph.interaction = Some((c, CanvasTool::Add(Tool::Work, ControlKind::Choice), true));
let mut input = Input::default();
let bounds = Rectangle::with_size(graph.size);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
bounds,
);
let Some(Message::TopologyCanvas(CanvasEvent::Begin { insertion, .. })) = send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
point,
bounds,
) else {
panic!("ordered arrow supports direct insertion");
};
assert_eq!(
insertion,
Insertion {
link: None,
before: Some(order[1])
}
);
assert_eq!(doc.bundle(), &before);
let mut state = State::default();
state
.begin(doc.project(), c, Tool::Work, ControlKind::Choice, insertion)
.unwrap();
state
.update(
doc.project(),
Event::Target(Choice {
id: f.work[1],
label: "Respond".into(),
}),
)
.unwrap();
doc.execute(state.prepare_pending(doc.project()).unwrap())
.unwrap();
let inserted = created(&f.p, doc.project(), f.owner);
assert_eq!(
doc.project().elements[&f.owner].steps().unwrap(),
&[order[0], inserted, order[1]]
);
let links = sequence_links(doc.project(), f.owner);
assert_eq!(links.len(), 2);
for (a, b) in [(order[0], inserted), (inserted, order[1])] {
assert!(
links
.iter()
.any(|link| link.source() == a && link.target() == b)
);
}
assert!(
Graph::new(doc.project(), f.owner, inserted)
.ordered_paths
.is_empty()
);
for id in f.work {
assert_eq!(doc.project().elements[&id], f.p.elements[&id]);
}
assert!(doc.undo());
assert_eq!(doc.bundle(), &before);
assert_eq!(doc.project(), &f.p);
}
}
#[test]
fn direct_process_palette_preserves_fit_and_manual_camera() {
for layer in [Layer::Oa, Layer::Sa] {
+28 -62
View File
@@ -59,21 +59,16 @@ pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize
let start = requested.min(len.saturating_sub(1) / 24) * 24;
start..(start + 24).min(len)
}
pub(super) fn view<'a>(
app: &'a App,
owner: Id,
mut out: Column<'a, Message>,
) -> Element<'a, Message> {
pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
let p = app.project();
let context = app.topology_context().expect("visible process context");
let state = &app.behavior.topology;
let pending = state.draft.is_some();
let explicit =
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
let ordered = !legacy_order(p, owner).is_empty();
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
let mut controls = Column::new().spacing(6);
if !pending && explicit {
if !pending {
controls = controls
.push(
row![
@@ -156,10 +151,22 @@ pub(super) fn view<'a>(
.spacing(6),
);
}
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if ordered{"Ordered exchange path · click a path to insert or empty canvas to continue; the edit preserves its order"}else{match state.tool{
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
if !pending && ordered {
controls = controls.push(button("Append exchange…").on_press(Message::TopologyCanvas(
CanvasEvent::AppendExchange(context),
)));
let commands = preserved_order(p, owner, None);
if !commands.is_empty() {
controls = controls.push(
button("Edit path links and guards")
.on_press(Message::Command(unique_batch(p, commands))),
);
}
}
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
let mut navigation = column![].spacing(6);
if let Some(Relation {
@@ -249,7 +256,7 @@ pub(super) fn view<'a>(
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
);
}
let diagram = if explicit {
let diagram = {
let mut graph = Graph::expanded_qualified(
p,
owner,
@@ -373,58 +380,6 @@ pub(super) fn view<'a>(
state.shelf.is_some(),
pending,
)
} else {
out = out
.push(
button(if state.focused {
"Restore explorer and properties"
} else {
"Focus canvas"
})
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context))),
)
.push(controls)
.push(navigation);
out = out.push(label(
"Ordered exchange path · append the next exchange or convert to explicit links.",
));
if !pending {
out = out.push(
button("Append exchange…")
.on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))),
).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step."));
}
let commands = preserved_order(p, owner, None);
if !commands.is_empty() {
out = out.push(
button("Convert ordered path to explicit links")
.on_press(Message::Command(unique_batch(p, commands))),
);
} else {
out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process."))
.push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))),
button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))),
button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6));
}
let scene = Scene::sequence(p, owner, app.selected);
let size = scene.size;
out = out.push(
scrollable(
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height),
)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(240.),
);
super::super::scenario_scroll::observe_topology(
context,
scenario::ScrollArea::Page,
scrollable(out.padding(18)),
)
};
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
@@ -451,6 +406,17 @@ pub(super) fn view<'a>(
p.named(link.target()),
guard
)));
} else if d.fixed && ordered {
form = form.push(label(match (d.before, d.after) {
(Some(before), Some(after)) => format!(
"Insert between {} → {} · preserve the ordered path",
p.named(before),
p.named(after)
),
(Some(before), None) => format!("Continue after {}", p.named(before)),
(None, Some(after)) => format!("Insert before {}", p.named(after)),
_ => "Start the ordered path".into(),
}));
} else if d.fixed
&& d.tool == Tool::Exchange
&& let Some(before) = d.before