Complete source 6 engineering workflows and preserve release validation evidence
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
//! Explicit SA endpoint decisions for an operational communication counterpart.
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct PortDraft {
|
||||
source: Option<CommunicationPortChoice>,
|
||||
target: Option<CommunicationPortChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PortOption {
|
||||
Reuse(Id),
|
||||
Create(ComponentPortKind, ComponentPortOrientation),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PortMenuChoice {
|
||||
option: PortOption,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PortMenuChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(p: &Project, value: &CommunicationPortChoice) -> PortMenuChoice {
|
||||
match value {
|
||||
CommunicationPortChoice::Reuse { port } => PortMenuChoice {
|
||||
option: PortOption::Reuse(*port),
|
||||
label: format!("Reuse {}", choice(p, *port).label),
|
||||
},
|
||||
CommunicationPortChoice::Create {
|
||||
kind, orientation, ..
|
||||
} => creation(*kind, *orientation),
|
||||
}
|
||||
}
|
||||
|
||||
fn creation(kind: ComponentPortKind, orientation: ComponentPortOrientation) -> PortMenuChoice {
|
||||
PortMenuChoice {
|
||||
option: PortOption::Create(kind, orientation),
|
||||
label: match kind {
|
||||
ComponentPortKind::Standard => "Create Standard port · bidirectional".into(),
|
||||
ComponentPortKind::Flow => format!("Create Flow port · {orientation:?}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl Transition {
|
||||
pub(super) fn sync_communication_ports(&mut self) {
|
||||
self.request.communication_endpoints = self
|
||||
.communication_ports
|
||||
.iter()
|
||||
.filter_map(|(id, draft)| {
|
||||
Some(CommunicationEndpointDecision {
|
||||
source: *id,
|
||||
source_port: draft.source.clone()?,
|
||||
target_port: draft.target.clone()?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
pub(super) fn choose_communication_port(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
exchange: Id,
|
||||
source: bool,
|
||||
choice: PortMenuChoice,
|
||||
) -> Result<(), String> {
|
||||
if self.request.direction != TransitionDirection::OaToSa
|
||||
|| !p
|
||||
.relations
|
||||
.get(&exchange)
|
||||
.is_some_and(|r| r.kind() == RelationKind::CommunicationMean)
|
||||
{
|
||||
return Err("Choose an operational communication means in this transition.".into());
|
||||
}
|
||||
let value = match choice.option {
|
||||
PortOption::Reuse(port) => CommunicationPortChoice::Reuse { port },
|
||||
PortOption::Create(kind, orientation) => {
|
||||
let id = Id::new_v4();
|
||||
let side = if source { "source" } else { "target" };
|
||||
let name = format!("{} · {side} port", p.named(exchange));
|
||||
CommunicationPortChoice::Create {
|
||||
id,
|
||||
alias: p.fresh_alias(&name, "port", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
kind,
|
||||
orientation,
|
||||
}
|
||||
}
|
||||
};
|
||||
let draft = self.communication_ports.entry(exchange).or_default();
|
||||
if source {
|
||||
draft.source = Some(value);
|
||||
} else {
|
||||
draft.target = Some(value);
|
||||
}
|
||||
self.sync_communication_ports();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn communication_view<'a>(
|
||||
&'a self,
|
||||
p: &'a Project,
|
||||
exchange: Id,
|
||||
) -> Option<Element<'a, Message>> {
|
||||
if self.request.direction != TransitionDirection::OaToSa {
|
||||
return None;
|
||||
}
|
||||
let relation = p.relations.get(&exchange)?;
|
||||
if relation.kind() != RelationKind::CommunicationMean {
|
||||
return None;
|
||||
}
|
||||
let mut content = column![
|
||||
text("System communication endpoints").size(14),
|
||||
text("Choose both component ports explicitly. Participant correspondence still maps entities to participants. New ports are created together with the reviewed exchange; interface and functional-port allocation decisions remain explicit.").size(12),
|
||||
].spacing(8).width(Fill);
|
||||
for source in [true, false] {
|
||||
let participant = if source {
|
||||
relation.source()
|
||||
} else {
|
||||
relation.target()
|
||||
};
|
||||
let owner = self
|
||||
.request
|
||||
.mappings
|
||||
.iter()
|
||||
.find(|m| m.source == participant)
|
||||
.and_then(|m| m.target)
|
||||
.or_else(|| {
|
||||
self.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == participant)
|
||||
.map(|c| c.target)
|
||||
});
|
||||
let mut options = vec![creation(
|
||||
ComponentPortKind::Standard,
|
||||
ComponentPortOrientation::Unset,
|
||||
)];
|
||||
for orientation in [
|
||||
ComponentPortOrientation::Out,
|
||||
ComponentPortOrientation::In,
|
||||
ComponentPortOrientation::InOut,
|
||||
] {
|
||||
options.push(creation(ComponentPortKind::Flow, orientation));
|
||||
}
|
||||
options.extend(
|
||||
p.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == Layer::Sa
|
||||
&& owner == e.parent
|
||||
&& owner.is_some()
|
||||
&& matches!(port_contract(e), Some(PortContract::Component { .. }))
|
||||
})
|
||||
.map(|e| selected(p, &CommunicationPortChoice::Reuse { port: e.id })),
|
||||
);
|
||||
let draft = self.communication_ports.get(&exchange);
|
||||
let value = draft
|
||||
.and_then(|d| {
|
||||
if source {
|
||||
d.source.as_ref()
|
||||
} else {
|
||||
d.target.as_ref()
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
self.request
|
||||
.communication_endpoints
|
||||
.iter()
|
||||
.find(|d| d.source == exchange)
|
||||
.map(|d| {
|
||||
if source {
|
||||
&d.source_port
|
||||
} else {
|
||||
&d.target_port
|
||||
}
|
||||
})
|
||||
});
|
||||
let value = value.map(|v| selected(p, v));
|
||||
if let Some(value) = &value
|
||||
&& !options.contains(value)
|
||||
{
|
||||
options.push(value.clone());
|
||||
}
|
||||
content = content
|
||||
.push(
|
||||
text(format!(
|
||||
"{} · {}",
|
||||
if source {
|
||||
"Source participant"
|
||||
} else {
|
||||
"Target participant"
|
||||
},
|
||||
p.named(participant)
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
pick_list(options, value, move |choice| {
|
||||
Message::Workflow(Event::CommunicationPort(exchange, source, choice))
|
||||
})
|
||||
.placeholder("Choose a component port")
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
Some(content.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_request(request: &TransitionRequest) -> BTreeMap<Id, PortDraft> {
|
||||
request
|
||||
.communication_endpoints
|
||||
.iter()
|
||||
.map(|d| {
|
||||
(
|
||||
d.source,
|
||||
PortDraft {
|
||||
source: Some(d.source_port.clone()),
|
||||
target: Some(d.target_port.clone()),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn communication_requires_two_explicit_ports_and_recovers_the_partial_choice() {
|
||||
let mut p = Project::empty();
|
||||
let oa_a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
|
||||
.unwrap();
|
||||
let oa_b = p
|
||||
.add_element(Kind::Actor, Layer::Oa, "Partner", None)
|
||||
.unwrap();
|
||||
let sa_a = p
|
||||
.add_element(Kind::System, Layer::Sa, "Controller", None)
|
||||
.unwrap();
|
||||
let sa_b = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Partner", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(RelationKind::CommunicationMean, "Radio", oa_a, oa_b);
|
||||
let mut workflow = Workflow::transition(&p, exchange).unwrap();
|
||||
let Workflow::Transition(t) = &mut workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
for (source, target) in [(oa_a, sa_a), (oa_b, sa_b)] {
|
||||
t.request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
}
|
||||
workflow.update(&p, Event::Preview).unwrap();
|
||||
let make_port = || creation(ComponentPortKind::Standard, ComponentPortOrientation::Unset);
|
||||
workflow
|
||||
.update(&p, Event::CommunicationPort(exchange, true, make_port()))
|
||||
.unwrap();
|
||||
let Workflow::Transition(t) = &workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(t.request.communication_endpoints.is_empty());
|
||||
assert!(!t.plan.conflicts.is_empty());
|
||||
let raw = workflow.recovery_snapshot().unwrap();
|
||||
let mut restored = Workflow::from_recovery(&p, raw.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), raw);
|
||||
restored
|
||||
.update(&p, Event::CommunicationPort(exchange, false, make_port()))
|
||||
.unwrap();
|
||||
let Workflow::Transition(t) = &restored else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(t.plan.conflicts.is_empty(), "{:?}", t.plan.conflicts);
|
||||
let decision = t.request.communication_endpoints[0].clone();
|
||||
let CommunicationPortChoice::Create { id: from, .. } = decision.source_port else {
|
||||
unreachable!()
|
||||
};
|
||||
let CommunicationPortChoice::Create { id: to, .. } = decision.target_port else {
|
||||
unreachable!()
|
||||
};
|
||||
let before = p.clone();
|
||||
let accepted = restored.update(&p, Event::Apply).unwrap().unwrap();
|
||||
assert_eq!(accepted.elements[&from].parent, Some(sa_a));
|
||||
assert_eq!(accepted.elements[&to].parent, Some(sa_b));
|
||||
assert!(accepted.relations.values().any(|r| matches!(r.body,
|
||||
RelationBody::BoundaryExchange { source, target, oriented: false, .. }
|
||||
if source == from && target == to)));
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ enum Saved {
|
||||
Transition {
|
||||
base: String,
|
||||
request: TransitionRequest,
|
||||
#[serde(default)]
|
||||
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
|
||||
},
|
||||
}
|
||||
pub(crate) fn endpoint(p: &Project, port: PortChoice) -> EndpointChoice {
|
||||
@@ -52,6 +54,40 @@ fn check(p: &Project, base: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
impl Workflow {
|
||||
pub(crate) fn from_recovery_after_source5_upgrade(
|
||||
p: &Project,
|
||||
legacy_base: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Self, String> {
|
||||
let mut saved: Saved = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
match &mut saved {
|
||||
Saved::Semantic(value) => {
|
||||
return crate::semantic_review::State::from_recovery_after_source5_upgrade(
|
||||
p,
|
||||
legacy_base,
|
||||
value.take(),
|
||||
)
|
||||
.map(|state| Self::Semantic(Box::new(state)));
|
||||
}
|
||||
Saved::Ports(value) => {
|
||||
return crate::port_workflow::State::from_recovery_after_source5_upgrade(
|
||||
p,
|
||||
legacy_base,
|
||||
value.take(),
|
||||
)
|
||||
.map(|state| Self::Ports(Box::new(state)));
|
||||
}
|
||||
Saved::Relation { base, .. }
|
||||
| Saved::ManualTrace { base, .. }
|
||||
| Saved::Transition { base, .. } => {
|
||||
if base != legacy_base {
|
||||
return Err("Workflow recovery does not match the accepted source-5 model identity and revision.".into());
|
||||
}
|
||||
*base = project_fingerprint(p);
|
||||
}
|
||||
}
|
||||
Self::from_recovery(p, serde_json::to_value(saved).map_err(|e| e.to_string())?)
|
||||
}
|
||||
pub fn recovery_snapshot(&self) -> Result<serde_json::Value, String> {
|
||||
let saved = match self {
|
||||
Self::Semantic(state) => Saved::Semantic(state.recovery_snapshot()?),
|
||||
@@ -84,6 +120,7 @@ impl Workflow {
|
||||
Self::Transition(d) => Saved::Transition {
|
||||
base: d.plan.base_fingerprint.clone(),
|
||||
request: d.request.clone(),
|
||||
communication_ports: d.communication_ports.clone(),
|
||||
},
|
||||
};
|
||||
serde_json::to_value(saved).map_err(|e| e.to_string())
|
||||
@@ -148,10 +185,28 @@ impl Workflow {
|
||||
}
|
||||
Ok(workflow)
|
||||
}
|
||||
Saved::Transition { base, request } => {
|
||||
Saved::Transition {
|
||||
base,
|
||||
request,
|
||||
mut communication_ports,
|
||||
} => {
|
||||
check(p, &base)?;
|
||||
if communication_ports.len() > crate::source_workspace::LIMITS.model.max_records {
|
||||
return Err("Recovered transition exceeds endpoint draft limits.".into());
|
||||
}
|
||||
for (id, draft) in communication::from_request(&request) {
|
||||
communication_ports.entry(id).or_insert(draft);
|
||||
}
|
||||
let plan = plan_transition(p, &request).map_err(|e| e.to_string())?;
|
||||
Ok(Self::Transition(Box::new(Transition { request, plan })))
|
||||
let mut transition = Transition {
|
||||
request,
|
||||
plan,
|
||||
communication_ports,
|
||||
};
|
||||
transition.sync_communication_ports();
|
||||
transition.plan =
|
||||
plan_transition(p, &transition.request).map_err(|e| e.to_string())?;
|
||||
Ok(Self::Transition(Box::new(transition)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user