Checkpoint source 7 workflows and in-progress diagram authoring redesign
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s

Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
This commit is contained in:
Codex
2026-09-10 10:06:55 -07:00
parent 4b6b5fc197
commit e2ecfea69b
162 changed files with 17595 additions and 926 deletions
+322 -30
View File
@@ -14,6 +14,8 @@ use oa_core::{transitions::*, *};
use std::collections::BTreeSet;
mod communication;
pub(crate) mod recovery;
#[cfg(test)]
mod transition_tests;
#[derive(Debug, Clone)]
pub enum Event {
@@ -30,9 +32,12 @@ pub enum Event {
Source(Choice),
Target(Choice),
Scope(String),
Destination(Choice),
MappingTarget(Id, Choice),
MappingKind(Id, Kind),
CommunicationPort(Id, bool, communication::PortMenuChoice),
OperationOmission(Id, bool),
OperationRationale(Id, String),
Resolve(Id, TransitionField, FieldDecision),
Preview,
}
@@ -265,6 +270,157 @@ pub struct Transition {
pub plan: TransitionPlan,
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
}
impl Transition {
fn presentation(&self, p: &Project) -> (Project, Vec<Id>) {
let mut candidate = p.clone();
for command in &self.plan.commands {
match command {
Command::CreateElement(e) | Command::EditElement(_, e) => {
candidate.elements.insert(e.id, e.clone());
}
_ => {}
}
}
let targets = self
.plan
.changes
.iter()
.map(|c| c.target)
.filter(|id| candidate.elements.contains_key(id))
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
(candidate, targets)
}
fn automatic_behavior(&self, p: &Project) -> bool {
self.request
.sources
.first()
.is_some_and(|id| crate::behavior::selected_behavior(p, *id).is_some())
}
fn new_counterpart(&self, p: &Project) -> bool {
self.request.sources.first().is_some_and(|source| {
self.plan
.changes
.iter()
.find(|c| c.source == *source)
.map_or_else(
|| {
!p.traces
.values()
.any(|t| t.oa == *source || t.sa == *source)
&& !self
.request
.mappings
.iter()
.any(|m| m.source == *source && m.target.is_some())
},
|c| c.action == TransitionAction::Create,
)
})
}
fn boundary_choice(&self, p: &Project, conflict: &TransitionConflict) -> bool {
conflict.field.is_none()
&& conflict.target.is_none()
&& conflict.message
== "Choose a counterpart kind explicitly; participant/system boundary decisions are never inferred."
&& conflict
.source
.and_then(|id| p.elements.get(&id))
.is_some_and(|e| {
matches!(
e.kind(),
Kind::Entity | Kind::Actor | Kind::System | Kind::SystemActor
)
})
}
fn destination_error(&self, p: &Project) -> Option<String> {
let id = self.request.destination_diagram?;
let (candidate, targets) = self.presentation(p);
match candidate.diagrams.get(&id) {
None => Some("The chosen result diagram is missing. Your choice is retained; choose another diagram.".into()),
Some(view) if !result_view_compatible(&candidate, view, &targets, self.request.direction.target_layer()) =>
Some("The chosen result diagram cannot show every transitioned element. Choose another diagram; no model changes have been applied.".into()),
_ => None,
}
}
}
fn result_view_compatible(p: &Project, diagram: &Diagram, targets: &[Id], layer: Layer) -> bool {
(diagram.layer == layer || diagram.projection == ProjectionKind::Traceability)
&& (diagram.projection.state_machine().is_none() || !targets.is_empty())
&& targets.iter().all(|id| {
p.elements.get(id).is_some_and(|e| {
diagram.projection.includes(e.kind())
&& diagram.projection.state_machine().is_none_or(|machine| {
oa_core::projection::state_machine_contains(p, machine, *id)
})
})
})
}
fn automatic_result_view(p: &Project, targets: &[Id], layer: Layer) -> Option<Id> {
p.diagrams
.values()
.filter(|d| d.layer == layer && result_view_compatible(p, d, targets, layer))
.filter_map(|d| {
Some((
match d.projection {
ProjectionKind::Allocation => 0,
ProjectionKind::Overview => 1,
ProjectionKind::Decomposition => 2,
_ => return None,
},
d.id,
))
})
.min()
.map(|(_, id)| id)
}
fn place_transition_results(
before: &Project,
candidate: &mut Project,
diagram: Id,
targets: &[Id],
) {
// Keep every accepted placement and route. Only newly introduced placements
// receive space below the accepted projected bounds, including tall/nested nodes.
let accepted_nodes = before.diagrams.get(&diagram).map(|d| &d.nodes);
let mut occupied = candidate.diagrams[&diagram].clone();
occupied
.nodes
.retain(|id, _| accepted_nodes.is_some_and(|nodes| nodes.contains_key(id)));
let projected = oa_core::projection::project_diagram(candidate, &occupied);
let mut bottom = projected
.nodes
.iter()
.map(|n| n.position.y + n.size.height)
.chain(
occupied
.nodes
.values()
.map(|n| n.position.y + n.size.height),
)
.fold(20.0_f32, f32::max)
+ 40.;
for target in targets {
if candidate.diagrams[&diagram].nodes.values().any(|n| {
n.element == *target && accepted_nodes.is_some_and(|nodes| nodes.contains_key(&n.id))
}) {
continue;
}
let node = candidate.place(diagram, *target, Position { x: 60., y: bottom });
let placement = candidate
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap();
placement.position = Position { x: 60., y: bottom };
bottom += placement.size.height + 40.;
}
}
impl Workflow {
pub fn manual_trace(p: &Project, selected: Id) -> Result<Self, String> {
if p.layer_of(selected).is_none()
@@ -458,7 +614,34 @@ impl Workflow {
_ => {}
},
Self::Transition(d) => {
match &event {
Event::OperationOmission(source, enabled) => {
d.request
.operation_omissions
.retain(|v| v.source != *source);
if *enabled {
d.request.operation_omissions.push(OperationOmission {
source: *source,
rationale: String::new(),
});
}
}
Event::OperationRationale(source, raw) => {
if let Some(decision) = d
.request
.operation_omissions
.iter_mut()
.find(|v| v.source == *source)
{
decision.rationale = raw.clone();
}
}
_ => {}
}
match event {
Event::Destination(choice) => {
d.request.destination_diagram = (!choice.id.is_nil()).then_some(choice.id);
}
Event::CommunicationPort(exchange, source, value) => {
d.choose_communication_port(p, exchange, source, value)?;
}
@@ -580,29 +763,27 @@ impl Workflow {
return Ok(Some(candidate));
}
let layer = d.request.direction.target_layer();
let diagram = d
.request
.destination_diagram
.or_else(|| {
candidate
.diagrams
.values()
.find(|d| d.layer == layer)
.map(|d| d.id)
})
.unwrap_or_else(|| candidate.ensure_diagram(layer));
for change in &d.plan.changes {
if candidate.elements.contains_key(&change.target) {
let index = candidate.diagrams[&diagram].nodes.len();
candidate.place(
diagram,
change.target,
Position {
x: 60. + (index % 3) as f32 * 280.,
y: 60. + (index / 3) as f32 * 150.,
},
);
let targets: Vec<_> = d
.plan
.changes
.iter()
.map(|c| c.target)
.filter(|id| candidate.elements.contains_key(id))
.collect();
let diagram = if let Some(id) = d.request.destination_diagram {
let view = candidate.diagrams.get(&id).ok_or("The chosen result diagram no longer exists. Choose another result diagram.")?;
if !result_view_compatible(&candidate, view, &targets, layer) {
return Err("The chosen result diagram cannot show every transitioned element. Choose a compatible result diagram.".into());
}
id
} else {
automatic_result_view(&candidate, &targets, layer)
.unwrap_or_else(|| candidate.ensure_diagram(layer))
};
// Explicit behavior destinations retain the core plan's
// placements; free-space placement is an architecture aid.
if !d.automatic_behavior(p) {
place_transition_results(p, &mut candidate, diagram, &targets);
}
return execute_batch(&candidate, &[])
.map(Some)
@@ -671,6 +852,9 @@ impl Workflow {
let kinds = RelationKind::ALL
.into_iter()
.filter(|kind| {
if *kind == RelationKind::ContextualLink {
return false;
}
*kind == RelationKind::ExchangeAllocation
|| Kind::ALL.into_iter().any(|source| {
source.supports(d.layer)
@@ -827,7 +1011,11 @@ impl Workflow {
}
}
Self::Transition(d) => {
content=content.push(text("Create or reconcile traced counterparts. Parent and performer remain independent. Descriptive updates are reviewed here; structural changes are created only for new counterparts.").size(12).color(style::MUTED));
content=content.push(text(if d.new_counterpart(p) {
"Create a linked counterpart in the other layer. The source element stays in its current layer."
} else {
"Review names and descriptions of existing traced counterparts. For structural changes, use Review counterpart changes…"
}).size(12).color(style::MUTED));
let scope = match d.request.scope {
DependencyScope::SelectedOnly => "Selected only",
DependencyScope::Required => "Required references",
@@ -856,6 +1044,63 @@ impl Workflow {
.spacing(10)
.align_y(iced::Center),
);
let (presentation, targets) = d.presentation(p);
let target_layer = d.request.direction.target_layer();
let automatic = if d.automatic_behavior(p) {
"Automatic · open the counterpart in Behavior".into()
} else if let Some(id) =
automatic_result_view(&presentation, &targets, target_layer)
{
format!("Automatic · {}", p.named(id))
} else {
format!("Automatic · create {target_layer} overview")
};
let mut diagrams = vec![Choice {
id: Id::nil(),
label: automatic,
}];
diagrams.extend(
presentation
.diagrams
.values()
.filter(|view| {
result_view_compatible(&presentation, view, &targets, target_layer)
})
.map(|view| Choice {
id: view.id,
label: format!("{} · {}", view.name, view.projection),
}),
);
let selected = d.request.destination_diagram.unwrap_or(Id::nil());
if !diagrams.iter().any(|choice| choice.id == selected) {
diagrams.push(Choice {
id: selected,
label: format!("Unavailable result diagram · {}", p.named(selected)),
});
}
let selected = diagrams
.iter()
.find(|choice| choice.id == selected)
.cloned();
content = content.push(
column![
text("Show result in").size(12),
pick_list(diagrams, selected, |choice| Message::Workflow(
Event::Destination(choice)
))
.width(Fill)
.text_size(12)
.padding(8)
]
.spacing(5),
);
if let Some(error) = d.destination_error(p) {
content = content.push(
text(error)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
let sources = d
.request
.sources
@@ -865,6 +1110,38 @@ impl Workflow {
.chain(d.plan.conflicts.iter().filter_map(|c| c.source))
.collect::<BTreeSet<_>>();
for source in sources {
if d.request.direction == TransitionDirection::SaToOa
&& matches!(
p.elements.get(&source).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(_),
message_kind: MessageKind::Call | MessageKind::Asynchronous,
..
},
..
})
)
{
let omission = d
.request
.operation_omissions
.iter()
.find(|v| v.source == source);
content=content.push(text(format!("Technical operation · {}",p.named(source))).size(13))
.push(checkbox(omission.is_some()).label("Explicitly omit the interface operation in the OA counterpart").on_toggle(move|enabled|Message::Workflow(Event::OperationOmission(source,enabled))));
if let Some(omission) = omission {
content = content.push(
text_input("Required engineering rationale", &omission.rationale)
.on_input(move |raw| {
Message::Workflow(Event::OperationRationale(source, raw))
})
.padding(8)
.size(12),
);
}
content=content.push(text("The SA operation remains unchanged. Correlated replies derive this Call's abstraction.").size(11));
}
let mapping = d.request.mappings.iter().find(|m| m.source == source);
let existing = p
.traces
@@ -947,6 +1224,11 @@ impl Workflow {
.padding(8)
.width(Fill),
);
if matches!(e.kind(), Kind::Entity | Kind::Actor)
&& d.request.direction == TransitionDirection::OaToSa
{
item = item.push(text("System is the system of interest being designed. System actor remains outside that boundary. Choose the intended role; it is never inferred from the name.").size(11));
}
}
}
if let Some(ports) = d.communication_view(p, source) {
@@ -995,7 +1277,7 @@ impl Workflow {
fields.sort_by_key(|(source, field)| (*source, *field as u8));
let mut general_conflicts = column![].spacing(8);
for conflict in &d.plan.conflicts {
if conflict.field.is_none() {
if conflict.field.is_none() && !d.boundary_choice(p, conflict) {
general_conflicts = general_conflicts.push(
text(&conflict.message)
.size(12)
@@ -1128,9 +1410,10 @@ impl Workflow {
}
content = content.push(
text(format!(
"{} changes · {} unresolved conflicts · {} modeling warnings",
"{} changes · {} required boundary choices · {} unresolved conflicts · {} modeling warnings",
d.plan.changes.len(),
d.plan.conflicts.len(),
d.plan.conflicts.iter().filter(|c| d.boundary_choice(p, c)).count(),
d.plan.conflicts.iter().filter(|c| !d.boundary_choice(p, c)).count(),
d.plan.warnings.len()
))
.size(12),
@@ -1142,7 +1425,7 @@ impl Workflow {
Self::Ports(_) => false,
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok),
Self::Transition(d) => d.plan.conflicts.is_empty(),
Self::Transition(d) => d.plan.conflicts.is_empty() && d.destination_error(p).is_none(),
};
if let Some(Err(error)) = relation_plan {
content = content.push(
@@ -1163,9 +1446,18 @@ impl Workflow {
.padding(10)
.style(style::nav)
.on_press(Message::Workflow(Event::Cancel)),
button(text("Apply reviewed change").size(12))
.padding(10)
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
button(
text(
if matches!(self, Self::Transition(d) if d.new_counterpart(p)) {
"Create counterpart"
} else {
"Apply reviewed change"
}
)
.size(12)
)
.padding(10)
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
]
.spacing(10)
]