Compare commits

...
20 changed files with 3687 additions and 48 deletions
Generated
+6
View File
@@ -2155,10 +2155,16 @@ dependencies = [
name = "oa-desktop"
version = "0.2.0"
dependencies = [
"axum",
"iced",
"iced_runtime",
"oa-core",
"oa-server",
"reqwest",
"rfd",
"serde",
"serde_json",
"tempfile",
"tokio",
]
+8 -4
View File
@@ -102,7 +102,7 @@ Reparenting a nested element through Properties moves its declaration, descendan
## Save and reopen
Ctrl+S saves, Ctrl+Shift+S saves as, and Ctrl+O opens. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save as, and Ctrl+O opens a local file. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
@@ -118,14 +118,18 @@ The default is `127.0.0.1:8787`, with a platform-local data directory. `--data-d
The API queries and edits complete models, canonical source, multi-file source projects, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation and source writer as the desktop. Writes require the returned ETag in `If-Match`. One revision protects the accepted model and source bundle together, including comment-only edits. The server retains the current revision only.
Desktop files and hosted resources transfer explicitly; the desktop has no direct server connection. Use **Save as** to export `system.json`, import it, then edit using the API. The model interchange endpoint accepts a complete JSON model or standalone canonical source:
Use **Hosted models...** in the desktop top bar to connect, **Publish as new** to upload the current authored source project, or select a catalog entry and choose **Open selected**. Diagrams, properties and text share the same local editing history. **Save to server** / Ctrl+S explicitly sends valid changes; invalid drafts and failed writes retain your work. A revision conflict preserves local work and checks the server for review, with read-only source files and an explicit, undoable **Reload server version**. **Save as local...** switches back to a local destination after successfully saving an `.arcadia` source project; JSON export keeps the hosted destination. See the [hosted desktop walkthrough](docs/server.md#edit-hosted-models-in-the-desktop) for conflict recovery and connection setup.
The desktop keeps bearer credentials in memory and accepts HTTPS, with HTTP allowed only on localhost/loopback. Hosted editing does not require a cloud account and does not provide automatic synchronization or merges.
Scripts can also import a complete JSON model or standalone canonical source through the model interchange endpoint:
```sh
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
-H 'Content-Type: application/json' --data-binary @system.json
```
The response supplies the service `resource_id` and ETag. After API changes, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file and open it in the desktop. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
The response supplies the service `resource_id` and ETag. Refresh the desktop catalog to open that resource; after API changes to an open model, use **Check server** to review them. Alternatively, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file for local interchange. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
To preserve authored files and comments, use `POST /api/v1/projects/import-source-project` with a JSON bundle containing `entry` and a `files` map of relative paths to source strings. `GET` and conditional `PUT /api/v1/projects/<resource-id>/source-project` exchange that complete accepted bundle. Include resolution uses only the submitted files; the server never opens the client's disk paths. See the [REST contract](docs/api.rst) for payloads, located diagnostics and admission limits.
@@ -142,4 +146,4 @@ cargo build --release --workspace --locked
- `crates/oa-desktop`: Iced state/drafts, typed forms, architecture/behavior views, source highlighting and file workflows.
- `crates/oa-server`: REST transport, conditional writes, bounded queries and atomic local store.
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, direct hosted editing, semantic merge tooling and production-scale collaboration remain outside this prototype.
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, semantic merge tooling and production-scale collaboration remain outside this prototype.
+38 -12
View File
@@ -1,5 +1,5 @@
use super::catalog::{capture_observation, collection, is_constructor, replace_observation, sides};
use super::historical::keyed_slots_overlap;
use super::historical::{keyed_slots_overlap, selector_slots};
use super::mutate::*;
use super::*;
use crate::model::*;
@@ -297,16 +297,22 @@ fn apply_canonical_slot(
let fact = replace_observation(fact, Layer::Sa, desired.clone())?;
write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false)
}
fn target_only(selector: &FacetSelector, source: Layer, target: Layer) -> bool {
match selector {
FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => {
chosen(keys, source).is_none() && chosen(keys, target).is_some()
fn member_present(fact: &FacetReview, layer: Layer) -> bool {
fn observed<T>(review: &Reviewed<T>, layer: Layer) -> &Observed<T> {
match layer {
Layer::Oa => &review.oa,
Layer::Sa => &review.sa,
}
FacetSelector::Literal(keys) => {
chosen(keys, source).is_none() && chosen(keys, target).is_some()
}
match fact {
FacetReview::Member { review, .. } | FacetReview::Literal { review, .. } => {
matches!(observed(review, layer), Observed::Value(true))
}
FacetSelector::Annotation(keys) => {
chosen(keys, source).is_none() && chosen(keys, target).is_some()
FacetReview::Annotation { review, .. } => {
matches!(observed(review, layer), Observed::Value(Some(_)))
}
FacetReview::Binding { review, .. } => {
matches!(observed(review, layer), Observed::Value(Some(_)))
}
_ => false,
}
@@ -320,15 +326,19 @@ fn preserve_constructor_members(
source_layer: Layer,
target_layer: Layer,
) -> Result<(), String> {
// A correspondence key can exist without membership on that side (for
// example an OA literal automatically projects the same SA literal key).
// Preserve actual target-only values, except previously reviewed removals.
for fact in initial_facts {
if !constructor_contains(group, &fact.selector)
|| !target_only(&fact.selector, source_layer, target_layer)
|| member_present(&fact.current, source_layer)
|| !member_present(&fact.current, target_layer)
{
continue;
}
let slot = target_slot(target, &fact.selector, target_layer)?;
let was_corresponding = baseline.facets.iter().any(|old| {
!target_only(&old.selector(), source_layer, target_layer)
member_present(old, source_layer)
&& target_slot(target, &old.selector(), target_layer).ok() == Some(slot.clone())
});
if was_corresponding {
@@ -723,12 +733,28 @@ fn build_review(
constructor_actions.push((target, selected.clone(), local_record));
}
let refreshed = semantic_facets(observed, &trace, &references)?;
let group_selectors: BTreeSet<_> = refreshed
let mut group_selectors: BTreeSet<_> = refreshed
.iter()
.chain(all.iter())
.map(|f| f.selector.clone())
.filter(|f| constructor_contains(selected, f))
.collect();
// A constructor copy can turn a live one-sided member into a
// pair. Its initial projection is not another reviewed fact.
// Established one-sided history still requires an explicit reset.
let paired_sides: BTreeSet<_> = group_selectors
.iter()
.map(selector_slots)
.filter(|slots| slots.len() == 2)
.flatten()
.collect();
group_selectors.retain(|selector| {
!paired_sides.contains(selector)
|| baseline
.facets
.iter()
.any(|old| old.selector() == *selector)
});
let mut group = vec![];
for selector in group_selectors {
match capture_facet(observed, oa, sa, &selector) {
+20 -1
View File
@@ -395,11 +395,30 @@ pub fn plan_transition(
commands: vec![],
};
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
let omitted = if request.direction == TransitionDirection::SaToOa {
let mut omitted = if request.direction == TransitionDirection::SaToOa {
sa_port_omissions(project)
} else {
BTreeSet::new()
};
// An explicit existing Legacy counterpart can be compared and reviewed
// without inventing an OA typed port. Only this port is retained: dependent
// SA implementation records and new counterpart requests stay omitted.
for mapping in &request.mappings {
if project
.elements
.get(&mapping.source)
.is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some())
&& mapping
.target
.and_then(|target| project.elements.get(&target))
.is_some_and(|e| {
e.layer() == Layer::Oa
&& crate::ports::port_contract(e) == Some(&PortContract::Legacy)
})
{
omitted.remove(&mapping.source);
}
}
let mut reported_omissions = BTreeSet::new();
if selected.len() != request.sources.len() {
conflict(
+392
View File
@@ -1917,3 +1917,395 @@ fn retired_source_branch_retains_historical_member_pair_in_both_directions() {
);
}
}
#[test]
fn constructor_closure_preserves_target_only_literals_in_both_directions() {
for reverse in [false, true] {
let mut p = Project::empty();
let oa = add(&mut p, Kind::DataType, Layer::Oa, "oa");
let sa = add(&mut p, Kind::DataType, Layer::Sa, "sa");
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
enumeration(&mut p, target, &["local"]);
let t = trace(&mut p, oa, sa);
let facts = semantic_facets(&p, &p.traces[&t], &BTreeMap::new()).unwrap();
p.traces
.get_mut(&t)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline {
facets: facts.into_iter().map(|f| f.current).collect(),
});
enumeration(&mut p, source, &["incoming"]);
let mut r = request(
source,
target,
vec![(
FacetSelector::Data(DataFacet::Constructor),
SemanticDecision::TakeSource,
)],
);
if reverse {
r.transition.direction = TransitionDirection::SaToOa;
}
let q = applied(&p, r);
assert!(
matches!(q.elements[&target].shared(), Some(SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals }, ..
}) if literals == &["incoming", "local"]),
"reverse={reverse}"
);
let base = q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
validate_semantic_baseline(base).unwrap();
let incoming = FacetSelector::Literal(KeyPair {
oa: Some("incoming".into()),
sa: Some("incoming".into()),
});
assert_eq!(
base.facets
.iter()
.filter(|f| f.selector() == incoming)
.count(),
1
);
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
assert!(
facts
.iter()
.filter(|f| matches!(f.selector, FacetSelector::Literal(_)))
.all(|f| f.drift == Drift::Unchanged)
);
}
}
#[test]
fn constructor_closure_pairs_signal_members_and_requires_history_reset_in_both_directions() {
for reverse in [false, true] {
for historical in [false, true] {
let mut p = Project::empty();
let oa = add(&mut p, Kind::Event, Layer::Oa, "oa");
let sa = add(&mut p, Kind::Event, Layer::Sa, "sa");
let oa_item = add(&mut p, Kind::ExchangeItem, Layer::Oa, "oa_item");
let sa_item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "sa_item");
let (source, target, source_item) = if reverse {
(sa, oa, sa_item)
} else {
(oa, sa, oa_item)
};
set(
&mut p,
source,
SharedBody::Event {
body: EventBody::Signal {
items: vec![source_item],
},
},
);
set(
&mut p,
target,
SharedBody::Event {
body: EventBody::Change {
expression: "old".into(),
},
},
);
let t = trace(&mut p, oa, sa);
let previous = FacetSelector::Member {
collection: ReferenceCollection::SignalItems,
keys: if reverse {
KeyPair {
oa: None,
sa: Some(sa_item),
}
} else {
KeyPair {
oa: Some(oa_item),
sa: None,
}
},
};
if historical {
let facts = semantic_facets(&p, &p.traces[&t], &BTreeMap::new()).unwrap();
let old = facts
.into_iter()
.find(|f| f.selector == previous)
.unwrap()
.current;
p.traces
.get_mut(&t)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline { facets: vec![old] });
}
let mut r = request(
source,
target,
vec![(
FacetSelector::Event(EventFacet::Constructor),
SemanticDecision::TakeSource,
)],
);
if reverse {
r.transition.direction = TransitionDirection::SaToOa;
}
r.references.insert(oa_item, sa_item);
if historical {
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
assert!(
!invalid.conflicts.is_empty(),
"Established one-sided history requires reset: reverse={reverse}"
);
r.reviews[0].reset.push(previous);
}
let q = applied(&p, r);
let target_item = if reverse { oa_item } else { sa_item };
assert!(
matches!(q.elements[&target].shared(), Some(SharedBody::Event { body: EventBody::Signal { items } }) if items == &[target_item])
);
let base = q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
validate_semantic_baseline(base).unwrap();
let members: Vec<_> = base
.facets
.iter()
.filter(|f| {
matches!(
f.selector(),
FacetSelector::Member {
collection: ReferenceCollection::SignalItems,
..
}
)
})
.collect();
assert_eq!(
members.len(),
1,
"reverse={reverse}, historical={historical}"
);
assert_eq!(
members[0].selector(),
FacetSelector::Member {
collection: ReferenceCollection::SignalItems,
keys: KeyPair {
oa: Some(oa_item),
sa: Some(sa_item)
}
}
);
assert!(
validation::validate(&q)
.iter()
.all(|issue| issue.severity != validation::Severity::Error)
);
}
}
}
#[test]
fn constructor_closure_does_not_restore_previously_corresponding_removed_members() {
for reverse in [false, true] {
let mut p = Project::empty();
let oa = add(&mut p, Kind::DataType, Layer::Oa, "oa");
let sa = add(&mut p, Kind::DataType, Layer::Sa, "sa");
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
enumeration(&mut p, source, &["paired"]);
let t = trace(&mut p, oa, sa);
let constructor = FacetSelector::Data(DataFacet::Constructor);
let directed = |facets| {
let mut r = request(source, target, facets);
if reverse {
r.transition.direction = TransitionDirection::SaToOa;
}
r
};
p = applied(
&p,
directed(vec![(constructor.clone(), SemanticDecision::KeepTarget)]),
);
if reverse {
// Explicitly establish the counterpart key while its target branch
// is absent, rather than silently changing the one-sided history.
let mut r = directed(vec![(
FacetSelector::Literal(KeyPair {
oa: Some("paired".into()),
sa: Some("paired".into()),
}),
SemanticDecision::KeepTarget,
)]);
r.reviews[0].reset.push(FacetSelector::Literal(KeyPair {
oa: None,
sa: Some("paired".into()),
}));
p = applied(&p, r);
}
enumeration(&mut p, source, &["incoming"]);
enumeration(&mut p, target, &["paired", "local"]);
let q = applied(
&p,
directed(vec![(constructor, SemanticDecision::TakeSource)]),
);
assert!(
matches!(q.elements[&target].shared(), Some(SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals }, ..
}) if literals == &["incoming", "local"]),
"reverse={reverse}"
);
validate_semantic_baseline(
q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap(),
)
.unwrap();
}
}
fn reverse_legacy_port_fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let oa_owner = add(&mut p, Kind::Actor, Layer::Oa, "oa owner");
let sa_owner = add(&mut p, Kind::SystemActor, Layer::Sa, "sa owner");
let oa = Id::new_v4();
let sa = Id::new_v4();
let mut legacy = make_port(&p, oa, oa_owner, "oa port", PortContract::Legacy).unwrap();
*legacy.shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
};
p.elements.insert(oa, legacy);
let typed = make_port(
&p,
sa,
sa_owner,
"sa port",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
)
.unwrap();
p.elements.insert(sa, typed);
let t = trace(&mut p, oa, sa);
(p, oa, sa, t)
}
#[test]
fn reverse_existing_port_review_retains_legacy_and_roundtrips_typed_history() {
for decision in [SemanticDecision::KeepTarget, SemanticDecision::Candidate] {
let (p, oa, sa, t) = reverse_legacy_port_fixture();
let mut r = request(
sa,
oa,
vec![(FacetSelector::Port(PortFacet::Constructor), decision)],
);
r.transition.direction = TransitionDirection::SaToOa;
r.traces.insert(sa, t);
let expected_direction = if decision == SemanticDecision::Candidate {
let mut candidate = p.elements[&oa].clone();
let Some(SharedBody::Port {
legacy: Some(old), ..
}) = candidate.shared_mut()
else {
panic!()
};
old.direction = PortDirection::In;
r.candidates.push(SemanticRecord::Element(candidate));
PortDirection::In
} else {
PortDirection::InOut
};
let q = applied(&p, r);
assert_eq!(q.elements[&sa], p.elements[&sa]);
assert_eq!(q.elements.len(), p.elements.len());
assert!(matches!(q.elements[&oa].shared(), Some(SharedBody::Port {
contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction, interface: None }),
}) if *direction == expected_direction));
let baseline = q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
validate_semantic_baseline(baseline).unwrap();
assert!(baseline.facets.iter().any(|f| matches!(
f,
FacetReview::Port(PortReview::Constructor(Reviewed {
oa: Observed::Value(PortConstructor::Legacy),
sa: Observed::Value(PortConstructor::Component),
}))
)));
assert_eq!(language::parse(&language::format(&q)).unwrap(), q);
assert_eq!(
persistence::decode(&serde_json::to_vec(&q).unwrap()).unwrap(),
q
);
}
}
#[test]
fn reverse_existing_port_review_rejects_typed_oa_proposals_without_mutation() {
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
let (p, oa, sa, _) = reverse_legacy_port_fixture();
let snapshot = p.clone();
let mut r = request(
sa,
oa,
vec![(FacetSelector::Port(PortFacet::Constructor), decision)],
);
r.transition.direction = TransitionDirection::SaToOa;
if decision == SemanticDecision::Candidate {
let mut candidate = p.elements[&oa].clone();
*candidate.shared_mut().unwrap() = p.elements[&sa].shared().unwrap().clone();
r.candidates.push(SemanticRecord::Element(candidate));
}
let plan = plan_semantic_transition(&p, r).unwrap();
assert!(!plan.conflicts.is_empty());
assert!(
plan.conflicts
.iter()
.all(|c| !c.contains("outside selected transition")),
"Must reach structural validation: {:?}",
plan.conflicts
);
assert!(prepare_semantic_transition(&p, &plan).is_err());
assert_eq!(p, snapshot);
}
}
#[test]
fn reverse_new_port_counterpart_remains_an_explicit_sa_only_omission() {
let (p, _, sa, _) = reverse_legacy_port_fixture();
let mut r = TransitionRequest::new(TransitionDirection::SaToOa, vec![sa]);
r.mappings.push(TransitionMapping {
source: sa,
target: None,
target_kind: Some(Kind::Port),
});
let plan = plan_transition(&p, &r).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert!(plan.changes.is_empty());
assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission")));
assert_eq!(apply_transition(&p, &plan).unwrap(), p);
}
+9
View File
@@ -13,5 +13,14 @@ path = "src/main.rs"
oa-core = { path = "../oa-core" }
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
rfd = "0.15"
serde.workspace = true
serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
[dev-dependencies]
iced_runtime = "0.14"
oa-server = { path = "../oa-server" }
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
tempfile = "3"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
+34 -3
View File
@@ -1,3 +1,4 @@
mod hosted;
use crate::forms::Draft;
use crate::{
document::Document,
@@ -280,6 +281,7 @@ impl std::fmt::Display for Choice {
}
#[derive(Debug, Clone, Copy)]
pub enum Pending {
HostedOpen,
New,
Demo,
Open,
@@ -299,6 +301,7 @@ pub struct FilePrompt {
#[derive(Debug, Clone)]
pub enum Message {
Hosted(crate::hosted::Event),
PortAnchor(crate::port_edit::Event),
Connection(crate::connection_edit::Event),
Menu(crate::model_menu::Event),
@@ -410,6 +413,7 @@ pub enum Message {
}
pub struct App {
pub hosted: crate::hosted::State,
pub connection: Option<crate::connection_edit::Session>,
pub model_menu: Option<crate::model_menu::Opened>,
pub source_tree: crate::source_tree::State,
@@ -525,6 +529,7 @@ impl App {
Kind::Activity
},
status: "Coastal rescue example · explore both engineering perspectives".into(),
hosted: Default::default(),
busy: false,
pending: None,
delete_pending: false,
@@ -844,7 +849,10 @@ impl App {
let source_version = self.source_version + 1;
let lint_running = self.lint_running;
let guide = std::mem::take(&mut self.guide);
let mut hosted = std::mem::take(&mut self.hosted);
hosted.leave_document();
*self = Self::with_project(project);
self.hosted = hosted;
self.window_size = window_size;
self.canvas_size = canvas_size;
self.source_version = source_version;
@@ -906,8 +914,12 @@ impl App {
}
}
pub fn view(&self) -> Element<'_, Message> {
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
.into()
let base = crate::context_menu::Popup::new(
crate::views::view(self),
crate::model_menu::popup(self),
)
.into();
crate::hosted::view::overlay(self, base)
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
@@ -1019,6 +1031,7 @@ impl App {
&& !matches!(
message,
Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(_, _)
| Message::SavePath(_)
@@ -1038,6 +1051,10 @@ impl App {
message,
Message::Escape | Message::Request(Pending::Close(_))
) {
if self.hosted.visible {
self.hosted.visible = false;
return Task::none();
}
if self.workflow.take().is_some() {
return Task::none();
}
@@ -1062,6 +1079,7 @@ impl App {
| Message::Lint(_)
| Message::Linted(_, _)
| Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(_, _)
| Message::SavePath(_)
@@ -1069,7 +1087,8 @@ impl App {
| Message::Exported(_)
);
if !modal_event
&& ((self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
&& ((self.hosted.visible && !matches!(message, Message::Hosted(_)))
|| (self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|| (self.file_prompt.is_some()
&& !matches!(
message,
@@ -1187,6 +1206,7 @@ impl App {
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::Hosted(event) => return self.hosted_event(event),
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
@@ -2490,6 +2510,9 @@ impl App {
return self.handle(Message::Save(false));
}
Message::Save(save_as) => {
if !save_as && self.hosted.session.is_some() {
return self.save_hosted();
}
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
self.continue_after_save = false;
self.pending = None;
@@ -2541,6 +2564,9 @@ impl App {
}
self.disk_project = Some(disk);
self.path = Some(receipt.path);
self.hosted.session = None;
self.hosted.review = None;
self.hosted.uncertain = None;
self.document.mark_saved(receipt.snapshot);
self.prune_closed_files(!self.document.dirty());
self.status = "All source files saved".into();
@@ -2879,6 +2905,7 @@ impl App {
fn proceed(&mut self, pending: Pending) -> Task<Message> {
self.pending = None;
match pending {
Pending::HostedOpen => self.open_hosted(),
Pending::New => {
self.reset(Project::empty(), None);
Task::none()
@@ -3037,6 +3064,10 @@ impl App {
}
}
Err(error) => {
// Installing a local repair workspace changes its destination,
// even while the last valid graphical model stays visible. A
// failed/cancelled Open that installs nothing retains its binding.
self.hosted.leave_document();
self.canvas_document = Id::new_v4();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.path = Some(path);
+568
View File
@@ -0,0 +1,568 @@
//! Desktop destination lifecycle; HTTP never becomes a second editing history.
use super::*;
use crate::hosted::{
Event, Operation, Outcome, Request, Review, Session,
client::{Connection, Failure, Remote},
};
impl App {
pub fn hosted_label(&self) -> String {
self.hosted.session.as_ref().map_or_else(
|| {
if self.dirty() {
"Unsaved changes".into()
} else if self.path.is_some() {
"All changes saved".into()
} else {
"Local project".into()
}
},
|session| {
format!(
"Hosted · revision {} · {}",
session.revision,
if self.dirty() {
"local changes"
} else {
"saved"
}
)
},
)
}
pub fn save_label(&self) -> &'static str {
if self.hosted.session.is_some() {
"Save to server"
} else {
"Save all"
}
}
fn hosted_problem(&mut self, detail: impl Into<String>) {
let detail = detail.into();
self.status = detail.clone();
self.hosted.error = Some(Failure {
detail,
code: "desktop_hosted".into(),
status: None,
uncertain: false,
published: None,
});
}
fn hosted_ready(&mut self) -> bool {
if self.pending_recovery.is_some() {
self.continue_after_save = false;
self.pending = None;
self.hosted.visible = false;
self.page = Page::Text;
self.status =
"Recover the interrupted source save before changing storage destinations.".into();
return false;
}
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
self.hosted.visible = false;
self.continue_after_save = false;
self.pending = None;
return false;
}
if self.source_workspace.detached_dirty() {
self.hosted.visible = false;
self.page = Page::Text;
self.status =
"Reattach or revert detached source drafts before saving to a server.".into();
self.continue_after_save = false;
self.pending = None;
return false;
}
true
}
fn hosted_start(
&mut self,
operation: Operation,
connection: Connection,
resource_id: Option<Id>,
) -> Task<Message> {
let request = Request {
id: Id::new_v4(),
document: self.canvas_document,
revision: self.document.revision(),
operation,
connection,
resource_id,
etag: self.hosted.session.as_ref().map(|s| s.etag.clone()),
snapshot: self.document.snapshot(),
};
self.hosted.request = Some(request.clone());
if operation != Operation::Check {
self.hosted.error = None;
}
self.busy = true;
self.status = match operation {
Operation::Catalog(_) => "Reading hosted models…",
Operation::Open(_) => "Opening hosted model…",
Operation::Publish => "Publishing source project…",
Operation::Save => "Saving to server…",
Operation::Check => "Checking current server revision…",
}
.into();
let query = self.hosted.query.clone();
let after = if operation == Operation::Catalog(true) {
self.hosted.next
} else {
None
};
Task::perform(
async move {
let result = match operation {
Operation::Catalog(_) => request
.connection
.catalog(&query, after)
.await
.map(Outcome::Catalog),
Operation::Open(id) => request.connection.open(id).await.map(Outcome::Remote),
Operation::Publish => request
.connection
.publish(request.snapshot.bundle.clone())
.await
.map(Outcome::Remote),
Operation::Save => request
.connection
.save(
request.resource_id.expect("save has a resource"),
request.etag.as_deref().expect("save has a precondition"),
request.snapshot.bundle.clone(),
)
.await
.map(Outcome::Remote),
Operation::Check => request
.connection
.open(request.resource_id.expect("check has a resource"))
.await
.map(Outcome::Remote),
};
Message::Hosted(Event::Completed(request.id, request.document, result))
},
|message| message,
)
}
pub(super) fn open_hosted(&mut self) -> Task<Message> {
if self.pending_recovery.is_some() {
self.hosted_problem(
"Recover the interrupted source save before opening another model.",
);
return Task::none();
}
if let Some((connection, id)) = self.hosted.open_target.take() {
return self.hosted_start(Operation::Open(id), connection, Some(id));
}
Task::none()
}
pub(super) fn save_hosted(&mut self) -> Task<Message> {
if !self.hosted_ready() {
return Task::none();
}
if self.hosted.review.is_some() {
self.hosted.visible = true;
self.hosted_problem("The server has a different version. Review it before saving; your local work is retained.");
self.continue_after_save = false;
self.pending = None;
return Task::none();
}
if let Some(session) = &self.hosted.session {
return self.hosted_start(
Operation::Save,
session.connection.clone(),
Some(session.resource_id),
);
}
Task::none()
}
fn check_hosted(&mut self) -> Task<Message> {
let destination = self
.hosted
.uncertain
.as_ref()
.and_then(|r| r.resource_id.map(|id| (r.connection.clone(), id)))
.or_else(|| {
self.hosted
.session
.as_ref()
.map(|s| (s.connection.clone(), s.resource_id))
});
if let Some((connection, id)) = destination {
return self.hosted_start(Operation::Check, connection, Some(id));
}
self.hosted_problem("The publication result is unknown. Refresh the catalog and inspect the hosted models before choosing another action.");
Task::none()
}
fn acknowledge_hosted(&mut self, request: &Request, remote: Remote) -> Task<Message> {
if remote.compiled.bundle != request.snapshot.bundle {
self.hosted.review = Some(Review {
connection: request.connection.clone(),
remote,
});
self.hosted.review_path = None;
self.hosted.visible = true;
self.hosted_problem("The server returned a different source version. Review it; the local history has been preserved.");
self.continue_after_save = false;
self.pending = None;
return Task::none();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.path = None;
self.disk_project = None;
self.document.mark_saved(request.snapshot.clone());
self.hosted.uncertain = None;
self.hosted.review = None;
self.hosted.review_path = None;
self.hosted.error = None;
self.status = format!(
"Hosted revision {} saved. Undo remains local until your next Save.",
remote.revision
);
// A late receipt may save an older snapshot; it must not close newer work.
if self.continue_after_save {
self.continue_after_save = false;
if request.revision == self.document.revision()
&& !self.dirty()
&& let Some(pending) = self.pending.take()
{
return self.proceed(pending);
}
self.pending = None;
}
Task::none()
}
pub(super) fn hosted_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::Show => {
self.hosted.visible = true;
self.model_menu = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
Event::Close => self.hosted.visible = false,
Event::Base(base) => {
self.hosted.base = base;
self.hosted.connection = None;
self.hosted.catalog.clear();
self.hosted.invalidate_catalog();
self.hosted.selected = None;
}
Event::Token(token) => {
self.hosted.token = token;
self.hosted.connection = None;
self.hosted.catalog.clear();
self.hosted.selected = None;
self.hosted.invalidate_catalog();
}
Event::Query(query) => {
if self.hosted.query != query {
self.hosted.query = query;
self.hosted.selected = None;
self.hosted.invalidate_catalog();
}
}
Event::Connect => match Connection::new(&self.hosted.base, self.hosted.token.clone()) {
Ok(connection) => {
self.hosted.invalidate_catalog();
return self.hosted_start(Operation::Catalog(false), connection, None);
}
Err(error) => {
self.status = error.detail.clone();
self.hosted.error = Some(error);
}
},
Event::More => {
if !self.hosted.catalog_stale
&& self.hosted.next.is_some()
&& let Some(connection) = &self.hosted.connection
{
return self.hosted_start(Operation::Catalog(true), connection.clone(), None);
}
}
Event::Select(id) => self.hosted.selected = Some(id),
Event::OpenSelected => {
if let (Some(connection), Some(id)) =
(&self.hosted.connection, self.hosted.selected)
{
self.hosted.open_target = Some((connection.clone(), id));
self.hosted.visible = false;
return self.handle(Message::Request(Pending::HostedOpen));
}
}
Event::Publish => {
if !self.hosted_ready() {
return Task::none();
}
let connection = match Connection::new(&self.hosted.base, self.hosted.token.clone())
{
Ok(connection) => connection,
Err(error) => {
self.status = error.detail.clone();
self.hosted.error = Some(error);
return Task::none();
}
};
return self.hosted_start(Operation::Publish, connection, None);
}
Event::Save => return self.save_hosted(),
Event::Check => return self.check_hosted(),
Event::ReviewFile(path) => {
if let Some(source) = self
.hosted
.review
.as_ref()
.and_then(|remote| remote.remote.compiled.bundle.files.get(&path))
{
self.hosted.review_editor = text_editor::Content::with_text(source);
self.hosted.review_path = Some(path);
}
}
Event::ReviewAction(action) => {
if !action.is_edit() {
self.hosted.review_editor.perform(action);
}
}
Event::ReviewBack => self.hosted.review_path = None,
Event::Reload => {
if !self.hosted_ready() {
return Task::none();
}
if let Some(review) = self.hosted.review.clone() {
let remote = review.remote;
let connection = review.connection;
{
match self.document.apply_compiled((*remote.compiled).clone()) {
Ok(_) => {
self.document.mark_saved(remote.compiled.clone());
self.hosted.session = Some(Session {
connection,
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.path = None;
self.disk_project = None;
self.hosted.review = None;
self.hosted.review_path = None;
self.hosted.uncertain = None;
self.hosted.error = None;
self.sync();
self.hosted.visible = false;
self.status = "Server version loaded. Undo restores the previous accepted local version.".into();
}
Err(error) => self.hosted_problem(error.to_string()),
}
}
}
}
Event::Local => {
self.hosted.visible = false;
return self.handle(Message::Save(true));
}
Event::Forget => {
if self.hosted.session.is_some() {
self.hosted_problem("Save as local before disconnecting this hosted model.");
} else {
self.hosted = Default::default();
self.hosted.visible = true;
}
}
Event::Completed(id, document, result) => {
let Some(request) = self.hosted.request.as_ref() else {
return Task::none();
};
if request.id != id
|| request.document != document
|| document != self.canvas_document
{
return Task::none();
}
let request = self.hosted.request.take().expect("matching request");
self.busy = false;
// A catalog is a read snapshot, never a live mirror. Known or
// uncertain writes and observed newer revisions invalidate only
// the service whose results are currently displayed.
let catalog_changed = match &result {
Ok(Outcome::Remote(remote)) => {
matches!(request.operation, Operation::Publish | Operation::Save)
// A changed bound head can enter/leave the active
// filter or page even when no row was displayed.
|| self.hosted.session.as_ref().is_some_and(|session| {
session.connection.base() == request.connection.base()
&& session.resource_id == remote.resource_id
&& session.etag != remote.etag
})
|| self.hosted.uncertain.as_ref().is_some_and(|failed| {
failed.operation == Operation::Save
&& failed.connection.base() == request.connection.base()
&& failed.resource_id == Some(remote.resource_id)
&& failed.etag.as_ref().is_some_and(|etag| etag != &remote.etag)
})
|| self.hosted.catalog.iter().any(|item| {
item.resource_id == remote.resource_id
&& item.revision != remote.revision
})
}
Err(error) => {
matches!(request.operation, Operation::Publish | Operation::Save)
&& (error.uncertain || error.status == Some(412))
}
Ok(Outcome::Catalog(_)) => false,
};
if catalog_changed
&& self
.hosted
.connection
.as_ref()
.is_some_and(|connection| connection.base() == request.connection.base())
{
self.hosted.invalidate_catalog();
}
match result {
Err(error) => {
let conflict =
error.status == Some(412) && request.operation == Operation::Save;
if error.uncertain || conflict {
let mut failed = request.clone();
if let Some(id) = error.published {
failed.resource_id = Some(id);
}
if matches!(request.operation, Operation::Save | Operation::Publish) {
self.hosted.uncertain = Some(failed);
}
}
self.status = error.detail.clone();
self.hosted.error = Some(error);
self.hosted.visible = true;
self.continue_after_save = false;
self.pending = None;
if conflict {
return self.check_hosted();
}
}
Ok(Outcome::Catalog(catalog)) => {
if let Some(session) = &mut self.hosted.session
&& session.connection.base() == request.connection.base()
{
session.connection = request.connection.clone();
}
if let Some(failed) = &mut self.hosted.uncertain
&& failed.connection.base() == request.connection.base()
{
failed.connection = request.connection.clone();
}
if let Some(review) = &mut self.hosted.review
&& review.connection.base() == request.connection.base()
{
review.connection = request.connection.clone();
}
self.hosted.connection = Some(request.connection);
if request.operation != Operation::Catalog(true) {
self.hosted.catalog.clear();
}
for item in catalog.items {
if !self
.hosted
.catalog
.iter()
.any(|old| old.resource_id == item.resource_id)
{
self.hosted.catalog.push(item);
}
}
self.hosted.next = catalog.next_after;
self.hosted.catalog_stale = false;
if !self
.hosted
.catalog
.iter()
.any(|item| Some(item.resource_id) == self.hosted.selected)
{
self.hosted.selected = None;
}
self.status = format!("{} hosted models shown", self.hosted.catalog.len());
}
Ok(Outcome::Remote(remote)) => match request.operation {
Operation::Open(_) => {
match Document::from_bundle(remote.compiled.bundle.clone(), LIMITS) {
Ok(document) => {
self.reset(document.project().clone(), None);
self.document = document;
self.source_workspace =
Workspace::new(self.document.bundle(), true);
self.sync();
self.hosted.session = Some(Session {
connection: request.connection,
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.status = "Hosted source project opened. Edits and Undo stay local until Save to server.".into();
}
Err(error) => self.hosted_problem(error.to_string()),
}
}
Operation::Publish | Operation::Save => {
return self.acknowledge_hosted(&request, remote);
}
Operation::Check => {
let acknowledged = self
.hosted
.uncertain
.as_ref()
.filter(|failed| {
failed.connection.base() == request.connection.base()
&& failed.resource_id == Some(remote.resource_id)
&& failed.snapshot.bundle == remote.compiled.bundle
})
.cloned();
if let Some(failed) = acknowledged {
return self.acknowledge_hosted(&failed, remote);
}
if self.hosted.session.as_ref().is_some_and(|session| {
session.connection.base() == request.connection.base()
&& session.resource_id == remote.resource_id
&& session.etag == remote.etag
}) {
self.hosted.review = None;
self.hosted.review_path = None;
self.status =
"The server revision is unchanged. Local edits are retained."
.into();
} else {
self.status = format!(
"Server revision {} is available. Review before replacing local work.",
remote.revision
);
self.hosted.review = Some(Review {
connection: request.connection.clone(),
remote,
});
self.hosted.review_path = None;
self.hosted.visible = true;
}
}
Operation::Catalog(_) => {
self.hosted_problem("Unexpected response to catalog request.")
}
},
}
}
}
Task::none()
}
}
#[cfg(test)]
mod tests;
+817
View File
@@ -0,0 +1,817 @@
use super::*;
use crate::hosted::client::Secret;
fn client() -> Connection {
Connection::new("http://127.0.0.1:8787", Secret::default()).unwrap()
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Hosted(event));
}
fn rename(app: &mut App, name: &str) {
app.document
.execute(Command::Rename(app.project().id, name.into()))
.unwrap();
app.sync();
}
fn bind(app: &mut App) -> Id {
let resource = Id::new_v4();
app.hosted.session = Some(Session {
connection: client(),
resource_id: resource,
revision: 1,
etag: "\"opaque-base\"".into(),
});
resource
}
fn remote(snapshot: Arc<CompiledSourceProject>, resource_id: Id, revision: u64) -> Remote {
Remote {
resource_id,
revision,
etag: format!("\"opaque-{revision}\""),
compiled: snapshot,
}
}
fn complete(app: &mut App, result: Result<Outcome, Failure>) {
let r = app.hosted.request.as_ref().unwrap();
send(app, Event::Completed(r.id, r.document, result));
}
fn failure(status: Option<u16>, uncertain: bool) -> Failure {
Failure {
detail: "Test failure".into(),
code: "test".into(),
status,
uncertain,
published: None,
}
}
#[test]
fn hosted_publish_acknowledges_only_the_sent_snapshot_and_retains_undo() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Published revision");
app.path = Some(PathBuf::from("local.arcadia"));
send(&mut app, Event::Publish);
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
assert!(app.busy);
rename(&mut app, "Later local edit");
let resource = Id::new_v4();
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 1))));
assert!(!app.busy);
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
assert!(app.path.is_none());
assert_eq!(app.project().name, "Later local edit");
assert!(app.document.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.project().name, "Published revision");
assert!(!app.document.dirty());
}
#[test]
fn hosted_conflict_fetches_review_without_adoption_and_reload_is_undoable() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let mut other = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
other
.execute(Command::Rename(other.project().id, "Other engineer".into()))
.unwrap();
rename(&mut app, "Local engineering work");
let local = app.document.snapshot();
send(&mut app, Event::Save);
complete(&mut app, Err(failure(Some(412), false)));
assert_eq!(
app.hosted.request.as_ref().unwrap().operation,
Operation::Check
);
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
complete(
&mut app,
Ok(Outcome::Remote(remote(other.snapshot(), resource, 2))),
);
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
assert_eq!(app.hosted.session.as_ref().unwrap().etag, "\"opaque-base\"");
send(&mut app, Event::Save);
assert!(!app.busy);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Reload);
assert_eq!(app.project().name, "Other engineer");
assert!(!app.document.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.project().name, "Local engineering work");
assert!(app.document.dirty());
}
#[test]
fn hosted_unknown_save_is_acknowledged_by_exact_readback_without_retry() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
rename(&mut app, "Saved despite lost response");
send(&mut app, Event::Save);
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
complete(&mut app, Err(failure(None, true)));
assert!(app.document.dirty());
assert!(!app.busy);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Check);
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 2))));
assert!(!app.document.dirty());
assert!(app.hosted.uncertain.is_none());
assert_eq!(app.hosted.session.as_ref().unwrap().revision, 2);
}
#[test]
fn hosted_stale_completions_do_not_clear_current_busy_or_adopt_another_document() {
let mut app = App::with_project(Project::empty());
bind(&mut app);
send(&mut app, Event::Save);
let request = app.hosted.request.as_ref().unwrap().clone();
send(
&mut app,
Event::Completed(Id::new_v4(), request.document, Err(failure(None, true))),
);
assert!(app.busy);
assert_eq!(app.hosted.request.as_ref().unwrap().id, request.id);
app.reset(Project::empty(), None);
let current = app.document.snapshot();
send(
&mut app,
Event::Completed(
request.id,
request.document,
Ok(Outcome::Remote(remote(request.snapshot, Id::new_v4(), 3))),
),
);
assert!(Arc::ptr_eq(&current, &app.document.snapshot()));
assert!(app.hosted.session.is_none());
}
#[test]
fn hosted_invalid_and_detached_source_drafts_never_start_network_writes() {
let mut app = App::with_project(Project::empty());
bind(&mut app);
app.source_workspace
.edit("arcadia 5; ?".into(), app.source_editor.cursor());
let draft = app.source_workspace.bundle();
let accepted = app.document.snapshot();
send(&mut app, Event::Save);
assert!(app.hosted.request.is_none());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), draft);
assert!(!app.busy);
}
#[test]
fn hosted_open_waits_for_unsaved_guard_and_failed_open_preserves_current_document() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Keep this work");
app.hosted.connection = Some(client());
app.hosted.selected = Some(Id::new_v4());
send(&mut app, Event::OpenSelected);
assert!(matches!(app.pending, Some(Pending::HostedOpen)));
assert!(app.hosted.request.is_none());
let _ = app.update(Message::Discard);
let previous = app.document.snapshot();
complete(&mut app, Err(failure(Some(401), false)));
assert!(Arc::ptr_eq(&previous, &app.document.snapshot()));
assert_eq!(app.project().name, "Keep this work");
assert!(app.document.dirty());
}
#[test]
fn hosted_only_successful_source_save_as_changes_the_active_destination() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
rename(&mut app, "Local fallback");
let failure = SaveFailure {
document: app.canvas_document,
message: "Disk failed".into(),
recovery: None,
};
let _ = app.update(Message::Saved(Err(failure)));
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join(&app.document.bundle().entry);
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
target.clone(),
None,
)
.unwrap();
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(app.hosted.session.is_none());
assert_eq!(app.path, Some(target));
assert!(!app.document.dirty());
assert!(app.document.can_undo());
}
#[test]
fn hosted_modal_blocks_underlying_edits_and_credentials_are_redacted() {
let mut app = App::with_project(Project::empty());
send(&mut app, Event::Show);
let original = app.document.snapshot();
let _ = app.update(Message::Command(Command::Rename(
app.project().id,
"Underlying edit".into(),
)));
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
let token = "a-secret-never-in-a-diagnostic";
let event = Message::Hosted(Event::Token(Secret(token.into())));
assert!(!format!("{event:?}").contains(token));
let _ = app.update(event);
send(&mut app, Event::Forget);
assert!(app.hosted.token.0.is_empty());
}
#[test]
fn hosted_detached_drafts_and_recovery_remain_available_after_rejected_publish() {
let mut app = App::with_project(Project::empty());
let mut bundle = app.document.bundle().clone();
bundle
.files
.insert("detached.arcadia".into(), "// kept draft".into());
let reachable = app.document.bundle().files.keys().cloned().collect();
app.source_workspace.discovered(&bundle, reachable);
app.source_workspace
.switch("detached.arcadia", app.source_editor.cursor());
app.source_workspace.edit(
"// unsaved detached rationale".into(),
app.source_editor.cursor(),
);
send(&mut app, Event::Publish);
assert!(app.hosted.request.is_none());
assert!(app.source_workspace.detached_dirty());
assert_eq!(
app.source_workspace.files["detached.arcadia"].text,
"// unsaved detached rationale"
);
app.pending_recovery = Some(PathBuf::from("interrupted.arcadia"));
send(&mut app, Event::Publish);
assert!(app.hosted.request.is_none());
assert_eq!(
app.pending_recovery,
Some(PathBuf::from("interrupted.arcadia"))
);
}
#[test]
fn hosted_failed_cross_service_publication_checks_and_reloads_its_own_resource() {
let mut app = App::with_project(Project::empty());
let old_id = bind(&mut app);
app.hosted.base = "http://127.0.0.1:9876".into();
rename(&mut app, "New publication");
send(&mut app, Event::Publish);
let new_id = Id::new_v4();
let mut error = failure(None, true);
error.published = Some(new_id);
complete(&mut app, Err(error));
send(&mut app, Event::Check);
let request = app.hosted.request.as_ref().unwrap();
assert_eq!(request.resource_id, Some(new_id));
assert_eq!(request.connection.base(), "http://127.0.0.1:9876/");
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
changed
.execute(Command::Rename(
changed.project().id,
"Changed after publication".into(),
))
.unwrap();
complete(
&mut app,
Ok(Outcome::Remote(remote(changed.snapshot(), new_id, 2))),
);
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, old_id);
send(&mut app, Event::Reload);
let session = app.hosted.session.as_ref().unwrap();
assert_eq!(session.resource_id, new_id);
assert_eq!(session.connection.base(), "http://127.0.0.1:9876/");
assert_eq!(app.project().name, "Changed after publication");
}
async fn dispatch(app: &mut App, message: Message) {
use iced::futures::StreamExt;
let mut tasks = vec![app.update(message)];
while let Some(task) = tasks.pop() {
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
while let Some(action) = stream.next().await {
if let iced_runtime::Action::Output(message) = action {
tasks.push(app.update(message));
}
}
}
}
}
#[tokio::test]
async fn hosted_actual_app_tasks_publish_edit_conflict_correct_credentials_reload_and_open() {
let directory = tempfile::tempdir().unwrap();
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
let token = "current-secret-for-hosted-regression-0123456789";
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, done) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, oa_server::app(store, Some(token.into())))
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
let mut app = App::with_project(Project::empty());
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Hosted rationale\n");
app.document.apply_bundle(bundle).unwrap();
app.sync();
app.hosted.base = base.clone();
app.hosted.token = Secret(token.into());
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert!(app.hosted.catalog.is_empty());
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
assert!(app.hosted.catalog_stale);
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
dispatch(&mut app, Message::Hosted(Event::Close)).await;
dispatch(&mut app, Message::Hosted(Event::Show)).await;
assert!(app.hosted.catalog_stale);
assert!(
app.hosted.request.is_none(),
"Showing the dialog does not poll"
);
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog.len(), 1);
assert_eq!(app.hosted.catalog[0].revision, 1);
dispatch(&mut app, Message::Hosted(Event::Close)).await;
let session = app
.hosted
.session
.clone()
.expect("publish binds a resource");
assert!(!app.busy);
assert!(!app.document.dirty());
assert!(
app.document.bundle().files[&app.document.bundle().entry].contains("// Hosted rationale")
);
let project_id = app.project().id;
dispatch(
&mut app,
Message::Command(Command::Rename(project_id, "Graphical update".into())),
)
.await;
dispatch(&mut app, Message::Save(false)).await;
assert!(app.hosted.catalog_stale);
assert_eq!(
app.hosted.catalog[0].revision, 1,
"Old results stay identified as a snapshot"
);
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog[0].revision, 2);
assert_eq!(app.hosted.catalog[0].name, "Graphical update");
let external = Connection::new(&base, Secret(token.into())).unwrap();
let remote = external.open(session.resource_id).await.unwrap();
assert_eq!(remote.compiled.decoded.project.name, "Graphical update");
let mut other = Document::from_bundle(remote.compiled.bundle.clone(), LIMITS).unwrap();
other
.execute(Command::Rename(project_id, "API engineering change".into()))
.unwrap();
external
.save(session.resource_id, &remote.etag, other.bundle().clone())
.await
.unwrap();
dispatch(
&mut app,
Message::Command(Command::Rename(project_id, "Local pending change".into())),
)
.await;
dispatch(&mut app, Message::Save(false)).await;
assert!(app.hosted.review.is_some());
assert_eq!(app.project().name, "Local pending change");
assert!(app.document.dirty());
// Simulate a retired credential in every preexisting reconciliation handle.
let old =
Connection::new(&base, Secret("retired-secret-012345678901234567890".into())).unwrap();
app.hosted.session.as_mut().unwrap().connection = old.clone();
app.hosted.uncertain.as_mut().unwrap().connection = old.clone();
app.hosted.review.as_mut().unwrap().connection = old;
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
let reconciled_write = app.hosted.uncertain.clone().unwrap();
// Reload the captured review directly. A fresh Check must not hide a stale
// credential in that review by replacing it with a newer response.
dispatch(&mut app, Message::Hosted(Event::Reload)).await;
assert_eq!(app.project().name, "API engineering change");
dispatch(&mut app, Message::Undo).await;
assert_eq!(app.project().name, "Local pending change");
dispatch(&mut app, Message::Save(false)).await;
assert!(!app.document.dirty());
// Independently exercise the corrected uncertain-write connection.
app.hosted.uncertain = Some(reconciled_write);
dispatch(&mut app, Message::Hosted(Event::Check)).await;
assert!(app.hosted.error.is_none());
assert!(app.hosted.uncertain.is_none());
let accepted = external.open(session.resource_id).await.unwrap();
assert_eq!(accepted.compiled.bundle, *app.document.bundle());
assert_eq!(
accepted.compiled.decoded.project.name,
"Local pending change"
);
let mut second = App::with_project(Project::empty());
second.hosted.base = base;
second.hosted.token = Secret(token.into());
dispatch(&mut second, Message::Hosted(Event::Connect)).await;
assert!(
second
.hosted
.catalog
.iter()
.any(|item| item.resource_id == session.resource_id)
);
send(&mut second, Event::Select(session.resource_id));
dispatch(&mut second, Message::Hosted(Event::OpenSelected)).await;
assert_eq!(second.document.bundle(), app.document.bundle());
assert_eq!(
second.hosted.session.as_ref().unwrap().resource_id,
session.resource_id
);
dispatch(
&mut second,
Message::Command(Command::Rename(project_id, "Saved before New".into())),
)
.await;
dispatch(&mut second, Message::Request(Pending::New)).await;
assert!(second.pending.is_some());
dispatch(&mut second, Message::SaveAndContinue).await;
assert!(second.hosted.session.is_none());
assert!(second.pending.is_none());
assert_ne!(second.project().id, project_id);
assert_eq!(
external
.open(session.resource_id)
.await
.unwrap()
.compiled
.decoded
.project
.name,
"Saved before New"
);
drop(external);
drop(app);
drop(second);
let _ = stop.send(());
tokio::time::timeout(std::time::Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn hosted_invalid_local_open_repair_saves_locally_and_never_replaces_old_resource() {
let directory = tempfile::tempdir().unwrap();
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, done) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, oa_server::app(store, None))
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
let mut app = App::with_project(Project::empty());
app.hosted.base = base.clone();
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
let session = app.hosted.session.clone().unwrap();
let accepted = app.document.snapshot();
let original_identity = app.canvas_document;
let external = Connection::new(&base, Secret::default()).unwrap();
let old = external.open(session.resource_id).await.unwrap();
let local = tempfile::tempdir().unwrap();
let path = local.path().join("repair.arcadia");
let invalid = "arcadia 5; ? // unfinished local work";
std::fs::write(&path, invalid).unwrap();
let path = path.canonicalize().unwrap();
dispatch(
&mut app,
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
)
.await;
assert_ne!(app.canvas_document, original_identity);
assert_eq!(app.path.as_ref(), Some(&path));
assert!(!app.source_workspace.applied);
assert_eq!(app.source_editor.text(), invalid);
assert!(
Arc::ptr_eq(&accepted, &app.document.snapshot()),
"A repair workspace must preserve the last valid graphical model"
);
assert!(!app.source_diagnostics.is_empty());
assert!(
app.hosted.session.is_none(),
"The installed local repair workspace must own its save destination"
);
let mut repaired = Project::empty();
repaired.name = "Independent local repair".into();
let repaired_source = language::format(&repaired);
dispatch(
&mut app,
Message::SourceEdit(text_editor::Action::SelectAll),
)
.await;
dispatch(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new(repaired_source),
))),
)
.await;
dispatch(&mut app, Message::ApplySource).await;
assert_eq!(app.project(), &repaired);
assert!(!app.source_dirty());
dispatch(&mut app, Message::Save(false)).await;
let remote_after = external.open(session.resource_id).await.unwrap();
assert_eq!(
remote_after.etag, old.etag,
"Saving a repaired local workspace must never target the old hosted resource"
);
assert_eq!(remote_after.compiled.bundle, old.compiled.bundle);
assert!(app.hosted.session.is_none());
assert!(app.hosted.review.is_none());
assert!(app.hosted.uncertain.is_none());
assert!(app.hosted.request.is_none());
assert_eq!(app.path.as_ref(), Some(&path));
assert!(!app.dirty());
let saved = source_files::load_project(&path, LIMITS).unwrap();
assert_eq!(saved.reachable_bundle(), *app.document.bundle());
assert_eq!(
app.hosted.base, base,
"Connection settings remain available in memory"
);
drop(external);
drop(app);
let _ = stop.send(());
tokio::time::timeout(std::time::Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
#[test]
fn hosted_failed_or_cancelled_local_open_keeps_the_existing_document_and_destination() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let accepted = app.document.snapshot();
let identity = app.canvas_document;
let source = app.source_workspace.bundle();
let directory = tempfile::tempdir().unwrap();
let unreadable = directory.path().join("missing.json");
let error = read_snapshot(unreadable).unwrap_err();
for result in [Ok(None), Err(error)] {
let _ = app.update(Message::Loaded(result));
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.canvas_document, identity);
assert_eq!(app.source_workspace.bundle(), source);
assert!(app.path.is_none());
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
}
}
fn catalog_result(app: &App, resource: Id, revision: u64) -> Outcome {
Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![crate::hosted::client::Summary {
resource_id: resource,
revision,
model_id: app.project().id,
name: format!("Catalog snapshot {revision}"),
elements: app.project().elements.len(),
relations: app.project().relations.len(),
}],
next_after: Some(resource),
})
}
#[test]
fn hosted_catalog_search_credentials_and_failed_refresh_never_mix_result_scopes() {
let mut app = App::with_project(Project::empty());
let first = Id::new_v4();
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, first, 1);
complete(&mut app, Ok(catalog));
assert!(!app.hosted.catalog_stale);
send(&mut app, Event::More);
assert_eq!(
app.hosted.request.as_ref().unwrap().operation,
Operation::Catalog(true)
);
let next_page = catalog_result(&app, Id::new_v4(), 1);
complete(&mut app, Ok(next_page));
assert_eq!(app.hosted.catalog.len(), 2);
send(&mut app, Event::Select(first));
send(&mut app, Event::Query("different search".into()));
assert!(app.hosted.catalog_stale);
assert!(app.hosted.next.is_none());
assert!(app.hosted.selected.is_none());
assert_eq!(app.hosted.catalog.len(), 2);
assert!(
app.hosted
.catalog_message()
.unwrap()
.contains("Connect / refresh")
);
send(&mut app, Event::More);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Connect);
complete(&mut app, Err(failure(None, false)));
assert!(app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog[0].resource_id, first);
send(&mut app, Event::Connect);
let second = Id::new_v4();
let catalog = catalog_result(&app, second, 1);
complete(&mut app, Ok(catalog));
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog.len(), 1);
assert_eq!(app.hosted.catalog[0].resource_id, second);
send(&mut app, Event::Query("different search".into()));
assert!(
!app.hosted.catalog_stale,
"Unchanged query preserves the snapshot"
);
send(
&mut app,
Event::Token(Secret("different-credential".into())),
);
assert!(app.hosted.catalog_stale);
assert!(app.hosted.catalog.is_empty());
assert!(app.hosted.connection.is_none());
assert!(app.hosted.next.is_none());
assert!(app.hosted.selected.is_none());
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, second, 2);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
assert!(app.hosted.catalog.is_empty());
assert!(app.hosted.connection.is_none());
assert!(app.hosted.catalog_stale);
assert!(app.hosted.request.is_none());
}
#[test]
fn hosted_catalog_invalidation_follows_service_writes_and_observed_revision_changes() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let other = Id::new_v4();
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, other, 7);
complete(&mut app, Ok(catalog));
rename(&mut app, "Saved to original service");
send(&mut app, Event::Save);
let saved = remote(app.document.snapshot(), resource, 2);
complete(&mut app, Ok(Outcome::Remote(saved)));
assert!(
!app.hosted.catalog_stale,
"A write to A cannot invalidate B's catalog"
);
assert_eq!(app.hosted.next, Some(other));
assert_eq!(app.hosted.catalog[0].resource_id, other);
send(&mut app, Event::Base("http://127.0.0.1:8787".into()));
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, resource, 2);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Check);
let changed = remote(app.document.snapshot(), resource, 3);
complete(&mut app, Ok(Outcome::Remote(changed)));
assert!(
app.hosted.catalog_stale,
"A known newer server revision invalidates old metadata"
);
assert!(app.hosted.next.is_none());
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, resource, 3);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Reload);
rename(&mut app, "Write with uncertain outcome");
send(&mut app, Event::Save);
complete(&mut app, Err(failure(None, true)));
assert!(app.hosted.catalog_stale);
assert!(app.hosted.next.is_none());
assert_eq!(app.hosted.catalog[0].revision, 3);
app.hosted.leave_document();
assert!(
app.hosted.catalog_stale,
"Leaving the hosted destination cannot make old results fresh"
);
}
#[test]
fn hosted_new_revision_invalidates_filtered_catalog_even_without_a_displayed_row() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Original");
let resource = bind(&mut app);
send(&mut app, Event::Query("Renamed".into()));
send(&mut app, Event::Connect);
complete(
&mut app,
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![],
next_after: None,
})),
);
assert!(!app.hosted.catalog_stale);
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
let mut unchanged = remote(app.document.snapshot(), resource, 1);
unchanged.etag = app.hosted.session.as_ref().unwrap().etag.clone();
send(&mut app, Event::Check);
complete(&mut app, Ok(Outcome::Remote(unchanged)));
assert!(
!app.hosted.catalog_stale,
"An unchanged bound head keeps the filtered snapshot fresh"
);
// A different engineer changes the hosted model so it now matches the filter.
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
changed
.execute(Command::Rename(changed.project().id, "Renamed".into()))
.unwrap();
send(&mut app, Event::Check);
complete(
&mut app,
Ok(Outcome::Remote(remote(changed.snapshot(), resource, 2))),
);
assert_eq!(app.hosted.review.as_ref().unwrap().remote.revision, 2);
assert!(
app.hosted.request.is_none(),
"No polling or automatic catalog request"
);
assert!(
app.hosted.catalog_stale,
"Known newer revision can change membership in an empty filtered catalog"
);
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
}
#[test]
fn hosted_changed_bound_head_does_not_invalidate_another_services_empty_filter() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
send(&mut app, Event::Query("Renamed".into()));
send(&mut app, Event::Connect);
complete(
&mut app,
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![],
next_after: None,
})),
);
send(&mut app, Event::Check);
let changed = remote(app.document.snapshot(), resource, 2);
complete(&mut app, Ok(Outcome::Remote(changed)));
assert!(app.hosted.review.is_some());
assert!(!app.hosted.catalog_stale);
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
assert!(app.hosted.request.is_none());
}
+31 -9
View File
@@ -102,7 +102,7 @@ impl Topic {
Self::Source => "Textual editing & Git",
Self::Recovery => "Checks & error recovery",
Self::Files => "Files & keyboard shortcuts",
Self::Server => "REST API & automation",
Self::Server => "Hosted models & REST API",
}
}
@@ -751,16 +751,42 @@ fn article(topic: Topic) -> Article {
Topic::Server => {
const {
Article {
summary: "The Rust REST service gives scripts and engineering-tool plugins access to the same model, commands, validation and source representation.",
summary: "Use optional hosted storage from the desktop, scripts and engineering-tool plugins. All clients work with the same model, validation and authored source.",
keywords: "server rest api automation ai plugin curl http etag if-match revision resource_id deploy hosted cad scripting openapi token",
sections: &[
example(
"Start a local service",
"Run the separate arcadia-server binary from a terminal. Its default address is loopback 127.0.0.1:8787. docs/server.md describes storage, authentication and Linux deployment.",
"arcadia-server --data-dir ./arcadia-models\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
"From the repository root, run the command below in a terminal and leave it running. The default address is loopback 127.0.0.1:8787, with platform-local storage. docs/server.md also covers installed executables, authentication and Linux deployment.",
"cargo run --locked -p oa-server --bin arcadia-server\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
),
walkthrough(
"Build a safe editing client",
"Open or publish a hosted model",
&[
"Choose Hosted models... in the top bar. Enter the service root, such as http://127.0.0.1:8787, without /api/v1. Enter the configured bearer token if required, then Connect / refresh.",
"Choose Publish as new to upload the current source project, including its files and comments, as a separate hosted resource. Its acknowledgement switches the current Save destination to that resource.",
"To open an existing resource, search the Hosted model catalog, select a model and choose Open selected. More models loads another page. Unsaved work uses the normal save/discard/cancel prompt.",
"Edit diagrams, properties or Textual model, then choose Save to server or press Ctrl+S. Valid drafts are applied; invalid drafts remain available to correct or revert. Edits and Undo stay local until Save.",
],
),
walkthrough(
"Review another client's changes",
&[
"Check server reads the current revision without replacing local content. A stale Save returns 412 and automatically checks the server for review; local work is retained.",
"Review the changed file list. View server text opens a read-only file; Back to review returns to the list. There is no automatic merge.",
"Reload server version adopts the reviewed source project as one undoable local change after checking unapplied drafts. Undo restores the previous accepted local version without changing the server. Reconcile your intended changes before saving again.",
"After an unknown write outcome, use Check previous write when offered. If publication may already have succeeded, inspect the refreshed catalog before publishing another copy.",
],
),
explain(
"Return to a local file",
"Choose Save as local... or Ctrl+Shift+S, then save an .arcadia entry to retain included files and comments. A successful source-project save makes that file the current destination and leaves the hosted resource unchanged. JSON exports the model only and retains the hosted destination and unsaved state. Canceling or a failed save also retains the hosted destination. Forget connection becomes available after leaving the hosted session.",
),
explain(
"Connection credentials",
"The desktop keeps credentials in memory only. Use HTTPS remotely; HTTP is allowed only for localhost or a loopback IP. Put the token in its own field, never the service URL. URLs with credentials, queries or fragments are rejected, and redirects are not followed. To update a token, enter it and Connect / refresh for the same service, then retry the check or Save.",
),
walkthrough(
"Automate model edits",
&[
"Create or import a project using /api/v1/projects or /api/v1/projects/import. Keep the returned service resource_id; it is distinct from the editable model UUID.",
"Read the project's envelope, source or model. Preserve its exact ETag as the precondition for your edit.",
@@ -769,10 +795,6 @@ fn article(topic: Topic) -> Article {
"Query /artifact-links and /traces to connect domain-tool artifacts to model records. The server stores these references; the integration is responsible for resolving its external artifacts.",
],
),
explain(
"Desktop files and hosted models",
"The desktop currently edits local model files. To exchange with the service, export its /source or /model response and open that file; upload a reviewed candidate through the API with its original revision precondition. The desktop does not maintain a live hosted editing session.",
),
explain(
"Deployment and persistence",
"Non-loopback binding requires a configured bearer token. Use the documented local-disk storage and deployment configuration. The service keeps the current revision and serializes accepted updates; it does not provide retained revision history or server-side undo. Stop the service cleanly before copying its committed store for backup.",
+621
View File
@@ -0,0 +1,621 @@
//! Optional HTTP transport. It never adopts a document or persists credentials.
use oa_core::{
Id, Project,
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle},
};
use reqwest::{Client, Method, Url, header};
use serde::{Deserialize, de::DeserializeOwned};
use std::{fmt, net::IpAddr, sync::Arc, time::Duration};
// The service permits two 8 MiB representations plus envelope framing. Keep
// transport/parser budgets finite even when connecting to a misconfigured host.
const MAX_RESPONSE_BYTES: usize = 17 * 1024 * 1024;
const MAX_PROBLEM_BYTES: usize = 256 * 1024;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(40);
const SOURCE_LIMITS: ProjectLimits = ProjectLimits {
max_files: 256,
max_include_depth: 32,
max_include_edges: 1_024,
model: oa_core::language::ParseLimits {
max_source_bytes: 8 * 1024 * 1024,
max_graph_objects: 5_000,
max_records: 20_000,
max_step_references: 50_000,
},
};
#[derive(Clone, Default)]
pub struct Secret(pub String);
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret([redacted])")
}
}
#[derive(Clone)]
pub struct Connection {
base: Url,
client: Client,
token: Secret,
}
impl fmt::Debug for Connection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Do not expose headers, credentials, or user-supplied URL text.
f.debug_struct("Connection").finish_non_exhaustive()
}
}
#[derive(Clone, Debug)]
pub struct Remote {
pub resource_id: Id,
pub revision: u64,
pub etag: String,
pub compiled: Arc<CompiledSourceProject>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Catalog {
pub items: Vec<Summary>,
pub next_after: Option<Id>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Summary {
pub resource_id: Id,
pub revision: u64,
pub model_id: Id,
pub name: String,
pub elements: usize,
pub relations: usize,
}
#[derive(Clone, Debug)]
pub struct Failure {
pub detail: String,
pub code: String,
pub status: Option<u16>,
/// A mutation may have committed. Read current state before another write.
pub uncertain: bool,
/// Publication was acknowledged, but its accepted source could not be read.
pub published: Option<Id>,
}
impl Failure {
fn local(code: &str, detail: &str) -> Self {
Self {
detail: detail.into(),
code: code.into(),
status: None,
uncertain: false,
published: None,
}
}
}
impl fmt::Display for Failure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.detail)
}
}
impl std::error::Error for Failure {}
#[derive(Deserialize)]
struct Envelope {
resource_id: Id,
revision: u64,
model: Project,
source_project: SourceBundle,
}
#[derive(Deserialize)]
struct AcceptedSource {
revision: u64,
source_project: SourceBundle,
}
#[derive(Deserialize)]
struct Problem {
code: String,
detail: String,
#[serde(default)]
issues: Vec<ProblemIssue>,
}
#[derive(Deserialize)]
struct ProblemIssue {
message: String,
path: Option<String>,
line: Option<usize>,
column: Option<usize>,
}
struct Reply {
status: u16,
etag: Option<String>,
body: Vec<u8>,
}
impl Connection {
/// `base` is the service root, optionally beneath a reverse-proxy path.
/// Requests append `api/v1/`; credentials belong in `token`, never the URL.
pub fn new(base: &str, token: Secret) -> Result<Self, Failure> {
Self::with_timeout(base, token, REQUEST_TIMEOUT)
}
fn with_timeout(base: &str, token: Secret, timeout: Duration) -> Result<Self, Failure> {
let invalid = || {
Failure::local(
"invalid_endpoint",
"Enter an HTTPS service URL, or HTTP on localhost/loopback, without credentials, query parameters or a fragment.",
)
};
let base = base.trim();
if base.contains('\\')
|| base.chars().any(char::is_control)
|| base.split_once("://").is_some_and(|(_, rest)| {
rest.split(['/', '?', '#'])
.next()
.is_some_and(|authority| authority.contains('@'))
})
{
return Err(invalid());
}
let mut base = Url::parse(base).map_err(|_| invalid())?;
let host = base.host_str().ok_or_else(invalid)?;
let loopback = host.eq_ignore_ascii_case("localhost")
|| host
.trim_matches(['[', ']'])
.parse::<IpAddr>()
.is_ok_and(|ip| ip.is_loopback());
if !(base.scheme() == "https" || (base.scheme() == "http" && loopback))
|| !base.username().is_empty()
|| base.password().is_some()
|| base.query().is_some()
|| base.fragment().is_some()
{
return Err(invalid());
}
if !base.path().ends_with('/') {
// set_path accepts the existing URL's encoded path without decoding
// it; this preserves an optional reverse-proxy prefix exactly.
base.set_path(&format!("{}/", base.path()));
}
let mut headers = header::HeaderMap::new();
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/json"),
);
if !token.0.is_empty() {
if !token.0.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(Failure::local(
"invalid_token",
"The bearer token must contain printable ASCII without whitespace.",
));
}
let mut value =
header::HeaderValue::from_str(&format!("Bearer {}", token.0)).map_err(|_| {
Failure::local(
"invalid_token",
"The bearer token is not a valid HTTP credential.",
)
})?;
value.set_sensitive(true);
headers.insert(header::AUTHORIZATION, value);
}
let mut builder = Client::builder()
.default_headers(headers)
.redirect(reqwest::redirect::Policy::none())
.retry(reqwest::retry::never())
.connect_timeout(Duration::from_secs(10).min(timeout))
.timeout(timeout)
.user_agent("Arcadia-Studio/hosted-desktop");
if loopback {
builder = builder.no_proxy();
}
let client = builder.build().map_err(|_| {
Failure::local(
"client_unavailable",
"The HTTP client could not be initialized.",
)
})?;
Ok(Self {
base,
client,
token,
})
}
pub fn base(&self) -> &str {
self.base.as_str()
}
fn endpoint(&self, path: &str) -> Url {
// All path suffixes are literals or UUIDs, never user-provided paths.
self.base
.join(&format!("api/v1/{path}"))
.expect("validated hierarchical base")
}
fn redact(&self, text: String) -> String {
let text = if self.token.0.is_empty() {
text
} else {
text.replace(&self.token.0, "[redacted]")
};
// Limit user-visible diagnostics independently of the response budget.
text.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.take(8_192)
.collect()
}
fn invalid_reply(&self, reply: &Reply, mutation: bool, detail: &str) -> Failure {
Failure {
detail: detail.into(),
code: "invalid_response".into(),
status: Some(reply.status),
uncertain: mutation,
published: None,
}
}
fn decode<T: DeserializeOwned>(&self, reply: &Reply, mutation: bool) -> Result<T, Failure> {
serde_json::from_slice(&reply.body).map_err(|_| {
self.invalid_reply(
reply,
mutation,
"The service returned an invalid JSON response. Local work is unchanged.",
)
})
}
fn etag(&self, reply: &Reply, mutation: bool) -> Result<String, Failure> {
reply
.etag
.as_deref()
.filter(|value| valid_etag(value))
.map(str::to_owned)
.ok_or_else(|| {
self.invalid_reply(
reply,
mutation,
"The service did not return a valid strong ETag. Local work is unchanged.",
)
})
}
async fn request(
&self,
method: Method,
url: Url,
bundle: Option<SourceBundle>,
etag: Option<&str>,
) -> Result<Reply, Failure> {
let mutation = method != Method::GET;
let expected_status = if method == Method::POST { 201 } else { 200 };
let mut request = self.client.request(method, url);
if let Some(bundle) = bundle {
let bytes = serde_json::to_vec(&bundle).map_err(|_| {
Failure::local(
"invalid_source_project",
"The source project could not be encoded for the service.",
)
})?;
if bytes.len() > 8 * 1024 * 1024 {
return Err(Failure::local(
"request_too_large",
"The source project exceeds the service's 8 MiB JSON request limit. Your local model remains available; save it locally or reduce the hosted project.",
));
}
request = request
.header(header::CONTENT_TYPE, "application/json")
.body(bytes);
}
if let Some(etag) = etag {
if !valid_etag(etag) {
return Err(Failure::local(
"invalid_etag",
"A captured strong server ETag is required to save. Check the server version first.",
));
}
request = request.header(header::IF_MATCH, etag);
}
let mut response = request.send().await.map_err(|error| Failure {
detail: if error.is_timeout() {
"The service request timed out. Check the server before retrying a write."
} else {
"The service could not be reached or the connection was interrupted. Check the URL, service and credentials."
}.into(),
code: if error.is_timeout() { "timeout" } else { "connection_failed" }.into(),
status: None,
uncertain: mutation && !error.is_connect() && !error.is_builder(),
published: None,
})?;
let status = response.status().as_u16();
let limit = if response.status().is_success() {
MAX_RESPONSE_BYTES
} else {
MAX_PROBLEM_BYTES
};
let uncertain =
mutation && (response.status().is_success() || response.status().is_server_error());
let oversized = || Failure {
detail:
"The service response exceeds the desktop transport limit. Local work is unchanged."
.into(),
code: "response_too_large".into(),
status: Some(status),
uncertain,
published: None,
};
if response
.content_length()
.is_some_and(|size| size > limit as u64)
{
return Err(oversized());
}
let etag = response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| Failure {
detail:
"The service response was interrupted. Check the server before retrying a write."
.into(),
code: "response_interrupted".into(),
status: Some(status),
uncertain,
published: None,
})? {
if chunk.len() > limit.saturating_sub(body.len()) {
return Err(oversized());
}
body.extend_from_slice(&chunk);
}
if status != expected_status {
let mut failure = Failure {
detail: format!("The service returned HTTP {status}. Local work is unchanged."),
code: if (300..400).contains(&status) {
"redirect_refused"
} else {
"http_error"
}
.into(),
status: Some(status),
uncertain,
published: None,
};
if let Ok(problem) = serde_json::from_slice::<Problem>(&body) {
failure.uncertain |= mutation && problem.code == "outcome_unknown";
// These service failures explicitly precede admission/commit.
// Other 5xx responses remain uncertain, including storage I/O.
if matches!(
(status, problem.code.as_str()),
(503, "overloaded") | (507, "store_capacity")
) {
failure.uncertain = false;
}
if !problem.code.is_empty()
&& problem.code.len() <= 80
&& problem
.code
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
{
failure.code = self.redact(problem.code);
}
let mut detail = problem.detail;
for issue in problem.issues.into_iter().take(20) {
detail.push('\n');
if let Some(path) = issue.path {
detail.push_str(&path);
if let Some(line) = issue.line {
detail.push_str(&format!(":{line}"));
if let Some(column) = issue.column {
detail.push_str(&format!(":{column}"));
}
}
detail.push_str(": ");
}
detail.push_str(&issue.message);
}
failure.detail = self.redact(detail);
}
return Err(failure);
}
Ok(Reply { status, etag, body })
}
pub async fn catalog(&self, query: &str, after: Option<Id>) -> Result<Catalog, Failure> {
let mut url = self.endpoint("projects");
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("limit", "100");
if !query.is_empty() {
pairs.append_pair("q", query);
}
if let Some(after) = after {
pairs.append_pair("after", &after.to_string());
}
}
let reply = self.request(Method::GET, url, None, None).await?;
let catalog: Catalog = self.decode(&reply, false)?;
let mut previous = after;
for item in &catalog.items {
if item.resource_id.is_nil()
|| item.model_id.is_nil()
|| item.revision == 0
|| previous.is_some_and(|id| item.resource_id <= id)
{
return Err(self.invalid_reply(
&reply,
false,
"The service returned an invalid project catalog.",
));
}
previous = Some(item.resource_id);
}
if catalog.items.len() > 100
|| catalog
.next_after
.is_some_and(|id| Some(id) != previous || catalog.items.is_empty())
{
return Err(self.invalid_reply(
&reply,
false,
"The service returned invalid catalog pagination.",
));
}
Ok(catalog)
}
pub async fn open(&self, id: Id) -> Result<Remote, Failure> {
let reply = self
.request(
Method::GET,
self.endpoint(&format!("projects/{id}")),
None,
None,
)
.await?;
let envelope: Envelope = self.decode(&reply, false)?;
let etag = self.etag(&reply, false)?;
if envelope.resource_id != id
|| id.is_nil()
|| envelope.revision == 0
|| envelope.model.id.is_nil()
{
return Err(self.invalid_reply(
&reply,
false,
"The service returned a different project identity or invalid revision.",
));
}
let compiled = self.compile(envelope.source_project, &reply, false).await?;
if compiled.decoded.project != envelope.model {
return Err(self.invalid_reply(
&reply,
false,
"The hosted source and model disagree. The document was not opened.",
));
}
Ok(Remote {
resource_id: id,
revision: envelope.revision,
etag,
compiled,
})
}
pub async fn publish(&self, bundle: SourceBundle) -> Result<Remote, Failure> {
let reply = self
.request(
Method::POST,
self.endpoint("projects/import-source-project"),
Some(bundle),
None,
)
.await?;
let receipt: Summary = self.decode(&reply, true)?;
if receipt.resource_id.is_nil() || receipt.model_id.is_nil() || receipt.revision == 0 {
return Err(self.invalid_reply(&reply, true, "The service returned an invalid publication receipt. Inspect the catalog before publishing again."));
}
let published = Some(receipt.resource_id);
let etag = self.etag(&reply, true).map_err(|mut failure| {
failure.published = published;
failure
})?;
let remote = self.open(receipt.resource_id).await.map_err(|mut failure| {
failure.uncertain = true;
failure.published = published;
failure.detail = format!("Publication was acknowledged, but its source could not be read. Open the published resource before trying another publication.\n{}", failure.detail);
failure
})?;
if remote.revision != receipt.revision
|| remote.etag != etag
|| remote.compiled.decoded.project.id != receipt.model_id
{
let mut failure = self.invalid_reply(&reply, true, "The published resource changed before its source was downloaded. Review the published resource before publishing again.");
failure.published = published;
return Err(failure);
}
Ok(remote)
}
pub async fn save(&self, id: Id, etag: &str, bundle: SourceBundle) -> Result<Remote, Failure> {
let reply = self
.request(
Method::PUT,
self.endpoint(&format!("projects/{id}/source-project")),
Some(bundle),
Some(etag),
)
.await?;
let accepted: AcceptedSource = self.decode(&reply, true)?;
let etag = self.etag(&reply, true)?;
if id.is_nil() || accepted.revision == 0 {
return Err(self.invalid_reply(
&reply,
true,
"The service returned an invalid saved revision.",
));
}
let compiled = self.compile(accepted.source_project, &reply, true).await?;
Ok(Remote {
resource_id: id,
revision: accepted.revision,
etag,
compiled,
})
}
async fn compile(
&self,
bundle: SourceBundle,
reply: &Reply,
mutation: bool,
) -> Result<Arc<CompiledSourceProject>, Failure> {
let result = tokio::task::spawn_blocking(move || {
let compiled = source_project::compile_bundle(&bundle, SOURCE_LIMITS)?;
Ok::<_, Vec<source_project::SourceDiagnostic>>((compiled, bundle))
})
.await
.map_err(|_| {
self.invalid_reply(
reply,
mutation,
"The hosted source could not be checked. Local work is unchanged.",
)
})?;
match result {
Ok((compiled, bundle)) if compiled.bundle == bundle && !compiled.decoded.project.id.is_nil() => Ok(Arc::new(compiled)),
Ok(_) => Err(self.invalid_reply(reply, mutation, "The service returned source without stable current-format identities. Local work is unchanged.")),
Err(issues) => {
let mut failure = self.invalid_reply(reply, mutation, "The hosted source could not be compiled. Local work is unchanged.");
for issue in issues.into_iter().take(10) {
failure.detail.push_str(&format!("\n{}:{}:{}: {}", issue.path, issue.diagnostic.line, issue.diagnostic.column, issue.diagnostic.message));
}
failure.detail = self.redact(failure.detail);
Err(failure)
}
}
}
}
fn valid_etag(value: &str) -> bool {
(2..=1_024).contains(&value.len())
&& value.starts_with('"')
&& value.ends_with('"')
&& value.as_bytes()[1..value.len() - 1]
.iter()
.all(|byte| matches!(*byte, 0x21 | 0x23..=0x7e))
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;
@@ -0,0 +1,609 @@
use super::*;
use axum::{
Json, Router,
http::StatusCode,
response::IntoResponse,
routing::{get, post, put},
};
use serde_json::json;
use std::{
collections::BTreeMap,
sync::atomic::{AtomicUsize, Ordering},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
sync::oneshot,
task::JoinHandle,
};
struct TestServer {
base: String,
stop: Option<oneshot::Sender<()>>,
task: JoinHandle<()>,
_directory: Option<tempfile::TempDir>,
}
impl TestServer {
async fn fixture(router: Router) -> Self {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, done) = oneshot::channel();
let task = tokio::spawn(async move {
axum::serve(listener, router)
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
Self {
base,
stop: Some(stop),
task,
_directory: None,
}
}
async fn production(token: Option<String>, prefix: bool) -> Self {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().to_owned();
let store = Arc::new(
tokio::task::spawn_blocking(move || oa_server::store::Store::open(path))
.await
.unwrap()
.unwrap(),
);
let router = oa_server::app(store, token);
let router = if prefix {
Router::new().nest("/teams/engineering", router)
} else {
router
};
let mut server = Self::fixture(router).await;
server._directory = Some(directory);
if prefix {
server.base.push_str("/teams/engineering");
}
server
}
fn connection(&self) -> Connection {
Connection::new(&self.base, Secret::default()).unwrap()
}
async fn finish(mut self) {
self.stop.take().unwrap().send(()).unwrap();
(&mut self.task).await.unwrap();
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.task.abort();
}
}
fn authored() -> SourceBundle {
let bundle = SourceBundle {
entry: "harbor.arcadia".into(),
files: BTreeMap::from([
("harbor.arcadia".into(), "// Harbor rationale 航海\r\narcadia 4; project harbor \"Harbor & rescue\" {\r\n include \"work/operations.arcadia\";\r\n}\r\n".into()),
("work/operations.arcadia".into(), "// Keep this authored organization\r\noa_activity assess \"Assess assistance\" {}\r\nsa_function assist \"Assist operator\" {}\r\n".into()),
]),
};
source_project::compile_bundle(&bundle, SOURCE_LIMITS)
.unwrap()
.bundle
}
fn envelope(bundle: SourceBundle, resource_id: Id, revision: u64) -> serde_json::Value {
let model = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
.unwrap()
.decoded
.project;
json!({"resource_id":resource_id,"revision":revision,"model":model,"source_project":bundle})
}
#[test]
fn hosted_endpoint_validation_redacts_secrets_and_preserves_proxy_paths() {
for base in [
"http://example.com",
"http://192.168.1.4",
"ftp://localhost",
"not a url",
"https://user:secret@example.com",
"https://@example.com",
"https://user@example.com",
"https://example.com/?token=secret",
"https://example.com/#secret",
"https://example.com\\hidden",
"https://example.com/\nsecret",
] {
let failure = Connection::new(base, Secret("secret".into())).unwrap_err();
assert_eq!(failure.code, "invalid_endpoint");
assert!(!failure.detail.contains("secret"));
assert!(!failure.uncertain);
}
for base in [
"http://localhost:8787",
"http://127.0.0.2:8787",
"http://[::1]:8787",
"https://example.com/prefix",
] {
assert!(Connection::new(base, Secret::default()).is_ok(), "{base}");
}
let client = Connection::new(
"https://example.com/teams/space%20name",
Secret("private-token".into()),
)
.unwrap();
assert_eq!(client.base(), "https://example.com/teams/space%20name/");
assert_eq!(
client.endpoint("projects").as_str(),
"https://example.com/teams/space%20name/api/v1/projects"
);
assert!(!format!("{client:?}").contains("private-token"));
assert_eq!(
format!("{:?}", Secret("private-token".into())),
"Secret([redacted])"
);
assert_eq!(
Connection::new("http://localhost", Secret("bad\nheader".into()))
.unwrap_err()
.code,
"invalid_token"
);
for etag in [
"*",
"W/\"weak\"",
"unquoted",
"\"one\",\"two\"",
"\"new\nline\"",
] {
assert!(!valid_etag(etag), "{etag}");
}
assert!(valid_etag("\"opaque:tag/+,=?\""));
}
#[tokio::test]
async fn hosted_production_roundtrip_preserves_comments_noops_and_conflicts() {
let server = TestServer::production(None, false).await;
let client = server.connection();
assert!(client.catalog("", None).await.unwrap().items.is_empty());
let bundle = authored();
let original = client.publish(bundle.clone()).await.unwrap();
assert_ne!(original.resource_id, original.compiled.decoded.project.id);
assert_eq!(original.compiled.bundle, bundle);
assert_eq!(original.revision, 1);
let catalog = client.catalog("Harbor &", None).await.unwrap();
assert_eq!(catalog.items.len(), 1);
assert_eq!(catalog.items[0].resource_id, original.resource_id);
assert_eq!(
catalog.items[0].model_id,
original.compiled.decoded.project.id
);
assert_eq!(catalog.items[0].name, "Harbor & rescue");
assert_eq!(
(catalog.items[0].elements, catalog.items[0].relations),
(2, 0)
);
assert!(
client
.catalog("?q=missing&limit=1000", None)
.await
.unwrap()
.items
.is_empty()
);
assert!(
client
.catalog("", Some(original.resource_id))
.await
.unwrap()
.items
.is_empty()
);
let same = client
.save(original.resource_id, &original.etag, bundle.clone())
.await
.unwrap();
assert_eq!(same.etag, original.etag);
assert_eq!(same.revision, original.revision);
let mut changed = bundle.clone();
changed
.files
.get_mut("work/operations.arcadia")
.unwrap()
.push_str("// Reviewed by the team\r\n");
let saved = client
.save(original.resource_id, &original.etag, changed.clone())
.await
.unwrap();
assert_eq!(saved.resource_id, original.resource_id);
assert_eq!(saved.revision, 2);
assert_ne!(saved.etag, original.etag);
assert_eq!(saved.compiled.bundle, changed);
assert_eq!(
saved.compiled.decoded.project,
original.compiled.decoded.project
);
let conflict = client
.save(original.resource_id, &original.etag, bundle)
.await
.unwrap_err();
assert_eq!(conflict.status, Some(412));
assert_eq!(conflict.code, "revision_conflict");
assert!(!conflict.uncertain);
assert_eq!(
client
.open(original.resource_id)
.await
.unwrap()
.compiled
.bundle,
changed
);
server.finish().await;
}
#[tokio::test]
async fn hosted_production_auth_proxy_prefix_and_located_rejection() {
let token = "hosted-test-token-with-at-least-32-characters";
let server = TestServer::production(Some(token.into()), true).await;
let unauthorized = server.connection().catalog("", None).await.unwrap_err();
assert_eq!(
(unauthorized.status, unauthorized.code.as_str()),
(Some(401), "unauthorized")
);
let client = Connection::new(&server.base, Secret(token.into())).unwrap();
let original = client.publish(authored()).await.unwrap();
assert_eq!(
client.catalog("Harbor &", None).await.unwrap().items.len(),
1
);
let mut invalid = original.compiled.bundle.clone();
invalid
.files
.get_mut("work/operations.arcadia")
.unwrap()
.push_str("this is not valid source;");
let rejected = client
.save(original.resource_id, &original.etag, invalid)
.await
.unwrap_err();
assert_eq!(rejected.status, Some(422));
assert!(!rejected.uncertain);
assert!(rejected.detail.contains("work/operations.arcadia:"));
assert_eq!(
client.open(original.resource_id).await.unwrap().etag,
original.etag
);
let missing = client.open(Id::new_v4()).await.unwrap_err();
assert_eq!(missing.status, Some(404));
assert!(!missing.uncertain);
server.finish().await;
}
#[tokio::test]
async fn hosted_redirect_is_not_followed_or_retried_and_problem_tokens_are_redacted() {
let redirected = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&redirected);
let destination = TestServer::fixture(Router::new().fallback(move || {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
StatusCode::OK
}
}))
.await;
let location = destination.base.clone();
let attempts = Arc::new(AtomicUsize::new(0));
let seen = Arc::clone(&attempts);
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
let location = location.clone();
let seen = Arc::clone(&seen);
async move { seen.fetch_add(1, Ordering::SeqCst); (StatusCode::TEMPORARY_REDIRECT, [(header::LOCATION, location)]) }
})).route("/api/v1/projects", get(|| async {
(StatusCode::FORBIDDEN, Json(json!({"code":"denied","detail":"Do not print private-test-token", "issues":[{"message":"private-test-token","path":"secret/private-test-token.arcadia","line":4,"column":2}]})))
}))).await;
let client = Connection::new(&server.base, Secret("private-test-token".into())).unwrap();
let failure = client.publish(authored()).await.unwrap_err();
assert_eq!(failure.code, "redirect_refused");
assert!(!failure.uncertain);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
assert_eq!(redirected.load(Ordering::SeqCst), 0);
let failure = client.catalog("", None).await.unwrap_err();
assert!(failure.detail.contains("[redacted]"));
assert!(!format!("{failure:?}").contains("private-test-token"));
server.finish().await;
destination.finish().await;
}
#[tokio::test]
async fn hosted_open_rejects_incoherent_envelopes_missing_etags_and_unstable_ids() {
let id = Id::new_v4();
let stable = authored();
let mut mismatched = envelope(stable.clone(), id, 1);
mismatched["model"]["name"] = json!("different model");
let mut wrong_id = envelope(stable.clone(), Id::new_v4(), 1);
// Keep this independently useful even if the model's schema changes.
wrong_id["revision"] = json!(1);
let mut unstable = envelope(stable.clone(), id, 1);
unstable["source_project"] = json!({"entry":"model.arcadia","files":{"model.arcadia":"arcadia 4; project unstable \"Unstable\" {}"}});
for (body, etag) in [
(mismatched, Some("\"opaque\"")),
(wrong_id, Some("\"opaque\"")),
(envelope(stable.clone(), id, 0), Some("\"opaque\"")),
(envelope(stable, id, 1), None),
(unstable, Some("\"opaque\"")),
] {
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/{id}",
get(move || {
let body = body.clone();
async move {
let mut response = Json(body).into_response();
if let Some(etag) = etag {
response
.headers_mut()
.insert(header::ETAG, etag.parse().unwrap());
}
response
}
}),
))
.await;
let failure = server.connection().open(id).await.unwrap_err();
assert_eq!(failure.code, "invalid_response");
assert!(!failure.uncertain);
server.finish().await;
}
}
#[tokio::test]
async fn hosted_put_preserves_an_opaque_etag_and_compiles_the_accepted_bundle() {
let id = Id::new_v4();
let etag = "\"opaque:tag/+,=?\"";
let bundle = authored();
let response_bundle = bundle.clone();
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/{id}/source-project",
put(
move |headers: header::HeaderMap, Json(body): Json<SourceBundle>| {
let expected_bundle = response_bundle.clone();
async move {
assert_eq!(headers.get(header::IF_MATCH).unwrap(), etag);
assert_eq!(body, expected_bundle);
(
[(header::ETAG, "\"accepted:new-tag\"")],
Json(json!({"revision":8,"changed":false,"source_project":body})),
)
}
},
),
))
.await;
let client = server.connection();
let saved = client.save(id, etag, bundle.clone()).await.unwrap();
assert_eq!(saved.etag, "\"accepted:new-tag\"");
assert_eq!(saved.resource_id, id);
assert_eq!(saved.revision, 8);
assert_eq!(saved.compiled.bundle, bundle.clone());
assert_eq!(
client.save(id, "*", bundle).await.unwrap_err().code,
"invalid_etag"
);
server.finish().await;
}
#[tokio::test]
async fn hosted_publication_retains_known_resource_after_failed_followup() {
let id = Id::new_v4();
let attempts = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&attempts);
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
(StatusCode::CREATED, [(header::ETAG, "\"receipt\"")], Json(json!({"resource_id":id,"revision":1,"model_id":Id::new_v4(),"name":"Published","elements":0,"relations":0})))
}
})).route("/api/v1/projects/{id}", get(|| async { StatusCode::SERVICE_UNAVAILABLE }))).await;
let failure = server.connection().publish(authored()).await.unwrap_err();
assert_eq!(failure.published, Some(id));
assert!(failure.uncertain);
assert_eq!(failure.status, Some(503));
assert_eq!(attempts.load(Ordering::SeqCst), 1);
server.finish().await;
}
/// Reads the entire request before simulating a lost/malformed response. The
/// counter proves the production client never repeats an admitted publication.
async fn raw_fixture(reply: Vec<u8>) -> (String, Arc<AtomicUsize>, JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let attempts = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&attempts);
let task = tokio::spawn(async move {
loop {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buffer = [0; 4096];
loop {
let read = stream.read(&mut buffer).await.unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase();
let length = headers
.lines()
.find_map(|line| {
line.strip_prefix("content-length:")
.and_then(|value| value.trim().parse::<usize>().ok())
})
.unwrap_or(0);
if request.len() >= end + 4 + length {
break;
}
}
}
calls.fetch_add(1, Ordering::SeqCst);
let _ = stream.write_all(&reply).await;
let _ = stream.shutdown().await;
}
});
(base, attempts, task)
}
#[tokio::test]
async fn hosted_lost_publication_response_is_uncertain_without_retry() {
let (base, attempts, task) = raw_fixture(Vec::new()).await;
let client = Connection::new(&base, Secret::default()).unwrap();
let failure = client.publish(authored()).await.unwrap_err();
assert!(failure.uncertain);
assert_eq!(failure.published, None);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
task.abort();
}
#[tokio::test]
async fn hosted_response_limits_cover_declared_and_chunked_bodies() {
let declared = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
MAX_RESPONSE_BYTES + 1
)
.into_bytes();
let mut chunked =
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".to_vec();
let chunk = vec![b' '; 64 * 1024];
for _ in 0..=(MAX_RESPONSE_BYTES / chunk.len()) {
chunked.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes());
chunked.extend_from_slice(&chunk);
chunked.extend_from_slice(b"\r\n");
}
chunked.extend_from_slice(b"0\r\n\r\n");
for reply in [declared, chunked] {
let (base, _, task) = raw_fixture(reply).await;
let failure = Connection::new(&base, Secret::default())
.unwrap()
.catalog("", None)
.await
.unwrap_err();
assert_eq!(failure.code, "response_too_large");
assert!(!failure.uncertain);
task.abort();
}
}
#[tokio::test]
async fn hosted_timeout_and_disconnect_leave_mutation_outcomes_explicit() {
let received = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&received);
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/import-source-project",
post(move || {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(100)).await;
StatusCode::CREATED
}
}),
))
.await;
let client =
Connection::with_timeout(&server.base, Secret::default(), Duration::from_millis(30))
.unwrap();
let failure = client.publish(authored()).await.unwrap_err();
assert_eq!(failure.code, "timeout");
assert!(failure.uncertain);
assert_eq!(received.load(Ordering::SeqCst), 1);
let base = server.base.clone();
server.finish().await;
let failure = Connection::with_timeout(&base, Secret::default(), Duration::from_secs(2))
.unwrap()
.catalog("", None)
.await
.unwrap_err();
assert!(matches!(
failure.code.as_str(),
"connection_failed" | "timeout"
));
assert!(!failure.uncertain);
}
#[tokio::test]
async fn hosted_publication_race_keeps_receipt_identity_for_review() {
let id = Id::new_v4();
let bundle = authored();
let published_model_id = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
.unwrap()
.decoded
.project
.id;
let mut changed = bundle.clone();
changed
.files
.get_mut("work/operations.arcadia")
.unwrap()
.push_str("// Concurrent external review\r\n");
let head = envelope(changed, id, 2);
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || async move {
(StatusCode::CREATED, [(header::ETAG, "\"first-revision\"")], Json(json!({"resource_id":id,"revision":1,"model_id":published_model_id,"name":"Published","elements":2,"relations":0})))
})).route("/api/v1/projects/{id}", get(move || {
let head = head.clone();
async move { ([(header::ETAG, "\"next-revision\"")], Json(head)) }
}))).await;
let failure = server.connection().publish(bundle).await.unwrap_err();
assert!(failure.uncertain);
assert_eq!(failure.published, Some(id));
assert!(failure.detail.contains("changed before"));
server.finish().await;
}
#[tokio::test]
async fn hosted_structured_mutation_failures_distinguish_precommit_rejections() {
for (status, code, uncertain) in [
(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large", false),
(StatusCode::INSUFFICIENT_STORAGE, "store_capacity", false),
(StatusCode::SERVICE_UNAVAILABLE, "overloaded", false),
(StatusCode::SERVICE_UNAVAILABLE, "storage_unavailable", true),
(StatusCode::GATEWAY_TIMEOUT, "outcome_unknown", true),
] {
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/import-source-project",
post(move || async move {
(
status,
Json(json!({"code":code,"detail":"Service-specific actionable detail"})),
)
}),
))
.await;
let failure = server.connection().publish(authored()).await.unwrap_err();
assert_eq!(failure.status, Some(status.as_u16()));
assert_eq!(failure.code, code);
assert_eq!(failure.uncertain, uncertain, "{code}");
assert_eq!(failure.detail, "Service-specific actionable detail");
server.finish().await;
}
}
#[tokio::test]
async fn hosted_oversized_local_bundle_is_rejected_before_network_admission() {
let mut bundle = authored();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str(&" ".repeat(8 * 1024 * 1024));
let failure = Connection::new("http://127.0.0.1:1", Secret::default())
.unwrap()
.publish(bundle)
.await
.unwrap_err();
assert_eq!(failure.code, "request_too_large");
assert!(!failure.uncertain);
assert!(failure.published.is_none());
}
+148
View File
@@ -0,0 +1,148 @@
//! Optional hosted transport state. The Document remains the local editing history.
pub mod client;
pub mod view;
use client::{Catalog, Connection, Failure, Remote, Secret, Summary};
use oa_core::{Id, source_project::CompiledSourceProject};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub enum Event {
Show,
Close,
Base(String),
Token(Secret),
Query(String),
Connect,
More,
Select(Id),
OpenSelected,
Publish,
Save,
Check,
Reload,
ReviewFile(String),
ReviewAction(iced::widget::text_editor::Action),
ReviewBack,
Local,
Forget,
Completed(Id, Id, Result<Outcome, Failure>),
}
#[derive(Debug, Clone)]
pub enum Outcome {
Catalog(Catalog),
Remote(Remote),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operation {
Catalog(bool),
Open(Id),
Publish,
Save,
Check,
}
#[derive(Debug, Clone)]
pub struct Session {
pub connection: Connection,
pub resource_id: Id,
pub revision: u64,
pub etag: String,
}
#[derive(Debug, Clone)]
pub struct Request {
pub id: Id,
pub document: Id,
pub revision: u64,
pub operation: Operation,
pub connection: Connection,
pub resource_id: Option<Id>,
pub etag: Option<String>,
pub snapshot: Arc<CompiledSourceProject>,
}
#[derive(Debug, Clone)]
pub struct Review {
pub connection: Connection,
pub remote: Remote,
}
pub struct State {
pub visible: bool,
pub base: String,
pub token: Secret,
pub query: String,
pub catalog: Vec<Summary>,
pub catalog_stale: bool,
pub next: Option<Id>,
pub selected: Option<Id>,
pub connection: Option<Connection>,
pub session: Option<Session>,
pub request: Option<Request>,
pub uncertain: Option<Request>,
pub review: Option<Review>,
pub review_path: Option<String>,
pub review_editor: iced::widget::text_editor::Content,
pub error: Option<Failure>,
pub open_target: Option<(Connection, Id)>,
}
impl Default for State {
fn default() -> Self {
Self {
visible: false,
base: "http://127.0.0.1:8787".into(),
token: Secret::default(),
query: String::new(),
catalog: vec![],
catalog_stale: true,
next: None,
selected: None,
connection: None,
session: None,
request: None,
uncertain: None,
review: None,
review_path: None,
review_editor: iced::widget::text_editor::Content::new(),
error: None,
open_target: None,
}
}
}
impl State {
pub fn invalidate_catalog(&mut self) {
self.catalog_stale = true;
self.next = None;
}
pub fn catalog_message(&self) -> Option<&'static str> {
if self.connection.is_none() {
Some("Connect / refresh to load models for this service.")
} else if self.catalog_stale {
Some("Catalog may be out of date. Connect / refresh to update these results.")
} else if self.catalog.is_empty() {
Some("No models found. Publish the current model or change the search.")
} else {
None
}
}
/// Keep explicit connection settings in memory, but never carry a resource
/// binding or in-flight result into another local document.
pub fn leave_document(&mut self) {
self.visible = false;
self.session = None;
self.request = None;
self.uncertain = None;
self.review = None;
self.review_path = None;
self.review_editor = iced::widget::text_editor::Content::new();
self.error = None;
self.open_target = None;
}
}
+238
View File
@@ -0,0 +1,238 @@
//! Hosted dialog. Main-header integration is a small caller-owned hook.
use super::{Event, Operation, client::Secret};
use crate::{
app::{App, Message},
style,
views::{action, label},
};
use iced::{
Element, Fill, Font,
widget::{self, button, column, container, row, scrollable, text, text_editor, text_input},
};
use std::collections::BTreeSet;
pub fn overlay<'a>(app: &'a App, base: Element<'a, Message>) -> Element<'a, Message> {
if !app.hosted.visible
|| app.pending.is_some()
|| app.file_prompt.is_some()
|| app.workflow.is_some()
|| app.delete_pending
{
return base;
}
widget::stack![
base,
widget::opaque(
container(
container(dialog(app))
.padding(22)
.width((app.window_size.width - 64.).clamp(680., 1000.))
.height((app.window_size.height - 80.).max(400.))
.style(style::card)
)
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
)
]
.into()
}
fn control<'a>(title: &'a str, event: Event, enabled: bool) -> widget::Button<'a, Message> {
button(text(title).size(12))
.padding([9, 12])
.style(style::nav)
.on_press_maybe(enabled.then_some(Message::Hosted(event)))
}
fn dialog(app: &App) -> Element<'_, Message> {
let state = &app.hosted;
let enabled = !app.busy;
if let Some(path) = &state.review_path {
return column![
text("Server source - read only").size(22),
text(path).size(13),
text_editor(&state.review_editor)
.font(Font::MONOSPACE)
.size(13)
.height(Fill)
.on_action(|action| Message::Hosted(Event::ReviewAction(action))),
control("Back to review", Event::ReviewBack, enabled),
]
.spacing(12)
.into();
}
let mut content = column![
label("SERVICE ADDRESS"),
text_input("http://127.0.0.1:8787", &state.base)
.padding(10)
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Base(value)))),
label("BEARER TOKEN - KEPT IN MEMORY ONLY"),
text_input("Optional for a loopback service", &state.token.0)
.secure(true)
.padding(10)
.on_input_maybe(
enabled.then_some(|value| Message::Hosted(Event::Token(Secret(value))))
),
row![
control("Connect / refresh", Event::Connect, enabled),
control(
"Forget connection",
Event::Forget,
enabled && state.session.is_none()
),
]
.spacing(8),
]
.spacing(8);
if let Some(session) = &state.session {
content = content.push(container(column![
text("Current hosted model").size(16),
text(session.connection.base()).size(12),
text(format!("Resource {} - revision {}", session.resource_id, session.revision)).size(12),
label("Edits and Undo stay local. Save sends the complete authored source project."),
row![
control("Check server", Event::Check, enabled),
control("Save as local...", Event::Local, enabled),
].spacing(8),
].spacing(8)).padding(12).style(style::panel));
}
if let Some(error) = &state.error {
content = content.push(
container(
column![
text(if error.uncertain {
"The write outcome is unknown"
} else {
"Hosted action needs attention"
})
.size(16),
text(&error.detail).size(13),
label("Your local model and source history are retained."),
]
.spacing(7),
)
.padding(12)
.style(style::panel),
);
}
if let Some(failed) = &state.uncertain {
if failed.operation == Operation::Publish {
content = content.push(text("A previous publication may already exist. Inspect the catalog before publishing another copy.").size(13));
}
if failed.resource_id.is_some() {
content = content.push(control("Check previous write", Event::Check, enabled));
}
}
if let Some(review) = &state.review {
let remote = &review.remote;
let local = &app.document.bundle().files;
let server = &remote.compiled.bundle.files;
let paths: BTreeSet<_> = local.keys().chain(server.keys()).collect();
let mut changes = column![
text(format!("Review server revision {}", remote.revision)).size(17),
label(format!("{} - resource {}", review.connection.base(), remote.resource_id)),
label("Reload replaces accepted local content as one undoable change. Unapplied drafts are checked first."),
].spacing(8);
if app.document.bundle().entry != remote.compiled.bundle.entry {
changes = changes.push(
text(format!(
"Entry: {} -> {}",
app.document.bundle().entry,
remote.compiled.bundle.entry
))
.size(12),
);
}
for path in paths {
if local.get(path) != server.get(path) {
let change = match (local.contains_key(path), server.contains_key(path)) {
(false, true) => "Added on server",
(true, false) => "Removed on server",
_ => "Changed",
};
changes = changes.push(
row![
text(format!("{change}: {path}")).size(12).width(Fill),
control(
"View server text",
Event::ReviewFile(path.clone()),
enabled && server.contains_key(path)
),
]
.spacing(8)
.align_y(iced::Center),
);
}
}
changes = changes.push(control("Reload server version", Event::Reload, enabled));
content = content.push(container(changes).padding(12).style(style::panel));
}
content = content.push(text("Hosted model catalog").size(17)).push(
row![
text_input("Search model names", &state.query)
.padding(9)
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Query(value))))
.on_submit(Message::Hosted(Event::Connect)),
control("Search", Event::Connect, enabled),
]
.spacing(8),
);
if let Some(message) = state.catalog_message() {
content = content.push(label(message));
}
for item in &state.catalog {
let selected = state.selected == Some(item.resource_id);
content = content.push(
button(
column![
text(&item.name).size(14),
label(format!(
"{} - revision {} - {} objects / {} links",
item.resource_id, item.revision, item.elements, item.relations
)),
]
.spacing(4),
)
.padding(10)
.width(Fill)
.style(if selected {
style::selected
} else {
style::nav
})
.on_press_maybe(enabled.then_some(Message::Hosted(Event::Select(item.resource_id)))),
);
}
if !state.catalog_stale && state.next.is_some() {
content = content.push(control("More models", Event::More, enabled));
}
let publish_label = if state
.uncertain
.as_ref()
.is_some_and(|r| r.operation == Operation::Publish)
{
"Publish another copy"
} else {
"Publish as new"
};
column![
text("Hosted models").size(24),
label("Optional shared storage for desktop, scripts and engineering integrations."),
scrollable(content.spacing(12)).height(Fill),
text(if app.busy { app.status.as_str() } else { "Publishing creates a separate hosted resource. Existing resources change only through Save to server." }).size(12),
row![
control("Close", Event::Close, enabled),
control(publish_label, Event::Publish, enabled),
control("Open selected", Event::OpenSelected, enabled && state.selected.is_some() && state.connection.is_some()),
control("Save to server", Event::Save, enabled && state.session.is_some() && state.review.is_none()),
].spacing(8),
].spacing(12).into()
}
// Kept here so a caller can integrate the feature without duplicating event wiring.
pub fn header_action() -> widget::Button<'static, Message> {
action("Hosted models...", Message::Hosted(Event::Show))
}
+1
View File
@@ -13,6 +13,7 @@ mod explorer_view;
mod forms;
mod guide;
mod highlight;
mod hosted;
mod model_menu;
mod navigation;
mod port_edit;
+5 -13
View File
@@ -55,22 +55,14 @@ pub fn view(app: &App) -> View<'_, Message> {
]
.spacing(10)
.width(220),
column![
text(short(&p.name, 46)).size(17),
label(if app.dirty() {
"Unsaved changes"
} else if app.path.is_some() {
"All changes saved"
} else {
"Local project"
})
]
.spacing(3)
.width(Fill),
column![text(short(&p.name, 46)).size(17), label(app.hosted_label())]
.spacing(3)
.width(Fill),
action("New", Message::Request(Pending::New)),
crate::hosted::view::header_action(),
action("Open", Message::Request(Pending::Open)),
action("Save as", Message::Save(true)),
button(text("Save all").size(12))
button(text(app.save_label()).size(12))
.padding([10, 16])
.on_press(Message::Save(false))
]
+4 -4
View File
@@ -14,7 +14,7 @@ Arcadia Studio implements a systems-engineering workbench spanning Operational A
| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references |
| Views and editing | Independent diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/functional/boundary exchange tools, contextual menus, SVG export, model explorer and typed properties |
| Text and storage | `arcadia 4` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
| Automation | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage |
| Automation and hosted editing | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows; optional desktop catalog/open/publish, explicit conditional Save, server review and local Save as; ETag preconditions, bounded requests and atomic current-revision storage |
Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically.
@@ -41,18 +41,18 @@ These are concrete design responses, not claims that every Capella user experien
| Hard-to-review model files | Modular typed source, stable aliases/UUIDs, preserved comments/formatting and source-aware history | Semantic merges, completion and an LSP are absent |
| Hidden mutation effects | Validated atomic commands, dependency cleanup, identity maintenance, previewed counterpart changes and undo | No distributed editing or automatic structural reconciliation |
| Platform/plugin overhead | Native Rust/Iced desktop and independent headless service | WGPU has substantial dependencies and needs a working graphics adapter |
| UI-coupled automation | Renderer-independent core and documented REST commands/queries | Desktop-hosted model editing is absent; transfer uses import/export |
| UI-coupled automation | Renderer-independent core, documented REST commands/queries and optional hosted desktop editing | Save and server review are explicit; there is no automatic synchronization or merge |
## Required first-release work
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, hosted desktop editing, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Direct state editing has scoped Windows/Linux acceptance recorded in [state evidence](../harness/release-state-editing.md). Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Hosted desktop editing is implemented; its remaining native acceptance is tracked in the release plan and current state. Direct state editing has scoped Windows/Linux acceptance recorded in [state evidence](../harness/release-state-editing.md). Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
## Remaining boundaries
- Logical and Physical Architecture, deployment semantics, executable behavior, complete SysML or Capella metamodel support, and native `.capella`/`.aird` import/export.
- Complete Capella communication/scenario variants beyond the specified Studio profile.
- Semantic merge, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock.
- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits; the current desktop transfers hosted data through explicit import/export until the planned client is implemented.
- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits. Desktop edits and Undo remain local until Save; revision conflicts require explicit review, and Reload is an undoable local operation. Credentials stay in memory and remote desktop connections require HTTPS.
- Production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers.
- Runtime plugin loading or an extension ABI. External tools can use the REST API; additional in-process infrastructure needs a concrete use case.
+34 -2
View File
@@ -85,9 +85,41 @@ For a native Linux installation, create an `arcadia` service account, install th
Ctrl+C/SIGTERM stops admission and drains HTTP requests. Already-started blocking tasks run to their completion boundary. The systemd example permits 45 seconds before a supervisor kill; an interrupted process recovers by loading the last installed envelopes. `/health/ready` means the initial store validation succeeded and the process can respond; it is not an ongoing disk-free-space or integrity probe.
## Desktop and automation integration
## Edit hosted models in the desktop
A desktop or automation client can query current JSON/source, edit with shared typed commands, validate/dry-run, and commit using the original ETag. For hosted editing, keep the user's draft until the service acknowledges the new revision. On `412`, preserve the draft and reload server state for review; a blind replacement retry would discard another client's work.
Start a local service from the repository root, then leave it running while using the desktop:
```sh
cargo run --locked -p oa-server --bin arcadia-server
```
The same command works in PowerShell and a Linux shell and uses the platform-local data directory described above. For a custom store, append `-- --data-dir PATH`; choose a local directory outside a synchronized folder. For an installed release, run `.\arcadia-server.exe --data-dir PATH` on Windows or `./arcadia-server --data-dir PATH` on Linux.
1. In the desktop top bar, choose **Hosted models...**. Enter `http://127.0.0.1:8787` as **Service address**, or your deployed service's HTTPS address. Use the service root, without `/api/v1`; a reverse-proxy path prefix is supported.
2. Enter the configured secret in **Bearer token** if required, then choose **Connect / refresh**. Tokens stay in application memory; they are not saved in model/source files. Leave the token empty for an unauthenticated loopback service.
3. To share the current local model, choose **Publish as new**. This uploads the complete authored source project, including its entry file, included files and comments, and creates a separate service resource. After acknowledgement, the current project's Save destination becomes that hosted resource; an existing local file is not overwritten.
4. To work on an existing resource, search the **Hosted model catalog**, select a model, and choose **Open selected**. **More models** loads another catalog page. Unsaved local work uses the normal save/discard/cancel prompt before opening.
5. Edit through diagrams, properties or **Textual model**. Choose **Save to server**, or press Ctrl+S, to validate/apply drafts and send the complete source project. The header shows the last acknowledged hosted revision and whether local changes remain. Edits and Undo/Redo stay local until an explicit Save.
An invalid source or property draft prevents publication/save and remains available to correct or revert. A rejected server write or failed connection retains the local model and source history. The application does not silently publish the last valid model while dropping an invalid draft. Detached source drafts must be reattached or reverted before hosted Save.
### Review a newer server version
An API client or another desktop can edit the same resource. Each Save includes the exact ETag of the desktop's last acknowledged revision. If the server has changed, it rejects that stale Save with `412 revision_conflict`; the desktop retains local work and checks the latest server version for review. Choose **Check server** to perform the same read explicitly, or retry the check after restoring a failed connection.
The review lists changed, added and removed source files. **View server text** opens a read-only file; **Back to review** returns to the list. Reading the server does not replace local content. **Reload server version** adopts the reviewed bundle as one undoable local change after checking unapplied drafts. Undo can restore the previous accepted local version, but it does not undo the server's revision. There is no automatic merge: inspect and reconcile the intended edits before saving again.
For a connection failure with an unknown write outcome, use **Check previous write** when offered before another Save. If the server holds exactly the submitted bundle, the desktop acknowledges that save. If it differs, review it. An uncertain publication may already have created a resource; refresh and inspect the catalog before choosing **Publish another copy**.
### Save locally and manage the connection
Choose **Save as local...** in Hosted models, or Ctrl+Shift+S, and select an `.arcadia` entry file to retain included files and comments. A successful source-project save switches future Ctrl+S operations to that local destination and leaves the hosted resource unchanged. Choosing JSON exports the model only and keeps the hosted Save destination and unsaved state. Canceling or failing the local save also keeps the hosted destination. **Forget connection** is available after leaving the hosted session and clears the remembered connection/token in memory.
The desktop accepts HTTPS service URLs and HTTP only for `localhost` or a loopback IP address. Credentials, query parameters and fragments in the URL are rejected; put the bearer token in its own field. Redirects are not followed. For remote access, configure a TLS reverse proxy as described above; the server itself does not provide TLS. To replace an expired token, enter the new value and choose **Connect / refresh** for the same service, then retry the check or Save. Connection settings and tokens are not persisted across application restarts.
## Automation integration
Automation clients use the same typed model, validation, authored source and revision preconditions as the desktop. Preserve the submitted draft and original ETag until a write is acknowledged; on `412`, read and reconcile the current version instead of blindly replacing it.
Use `POST /api/v1/projects/import-source-project` to import `{"entry":"model.arcadia","files":{"model.arcadia":"..."}}`. Use `GET` and conditional `PUT /api/v1/projects/{id}/source-project` to export or edit that complete authored workspace. These routes preserve comments, line endings, entry names and module layout. Missing declaration IDs are inserted once; keep the accepted bundle returned by PUT or subsequent GET. Candidate validation accepts `{"format":"source-project","source_project":{...}}` and reports diagnostics without adoption.
@@ -0,0 +1,47 @@
# Constructor history correction
Scope: `oa-core` only, based on HOST `f6a59de85b815fbea5ec578bb79d0a15819c42b4`. No desktop, source grammar, API shape, or OA port semantics changed by the keyed closure correction.
## Reproduction and decision
The original independent Boolean-to-Enumeration probe passes OA to SA on an unmodified HOST core. Extending the same fixture to SA to OA reproduces the duplicate `Literal(incoming,incoming)` conflict. New production-core regressions reproduce both the literal and `Event.Signal` member failures before the implementation change; `constructor-closure-red.log` records two failures. This is directional catalog projection, not UUID ordering: a live SA-only selector survives alongside the newly paired selector after a constructor copy.
Constructor closure now removes that redundant transient one-sided projection when a pair covers it. Established historical one-sided selectors remain, so changing their correspondence still requires an explicit reset. Both-sided historical pairs and removed branch observations retain their existing rules.
Suppressing the duplicate exposed another reverse-direction defect: target-local literals were lost because their implicit canonical key looked paired even when the source had no member. Preservation now uses observed membership (`true` or an optional row containing a value), with the existing prior source-membership guard preventing resurrection of reviewed removals. This implements the existing language.rst requirement to preserve target-only members when a historical switch reaches the target's current constructor; no normative contract was relaxed.
The intermediate normalization-only run (`constructor-closure-normalized.log`) passes Signal pairing but fails reverse literal preservation. Three final regressions cover both directions, no-history paired Signal insertion, established one-sided history requiring reset, local literal retention, and previously corresponding removed members staying removed. A separate read-only review checked selector normalization and presence semantics; its deletion-guard test recommendation was implemented.
## Verification
Dedicated target: `D:/CodexBuilds/Capella2-scenario-target`.
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
cargo test --locked --offline -p oa-core --test r3_semantic
cargo test --locked --offline -p oa-core
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
cargo fmt --all -- --check
cargo run --offline --manifest-path C:/Users/sorok/AppData/Local/Temp/capella2-constructor-literal-host-probe/Cargo.toml
```
All 31 semantic tests and all 356 Windows core tests pass. Strict core all-target Clippy and workspace formatting pass. The independently adapted probe passes both directions against the final implementation (`constructor-independent-green.log`). Full logs: `constructor-core-test.log`, `constructor-core-clippy.log`, `constructor-r3-semantic-green.log` in the dedicated target. Actual GUI/API acceptance and Linux integration remain with the root engineer; this core-only milestone makes no native acceptance claim.
## Separate follow-up: review an existing OA Legacy port
The shared transition planner previously removed every typed SA port before resolving explicit existing-target mappings. Consequently an existing trace could not Keep or supply a Legacy Candidate in SA-to-OA semantic review: it failed with `Review source is outside selected transition mappings`. Two focused regressions reproduced that boundary on the committed keyed correction (`existing-port-red.log`).
The language contract permits same-kind Port traces and reviewed updates from either layer, while retaining OA Legacy-only compatibility. The planner now retains only explicitly mapped SA ports whose selected existing OA target is Legacy. It does not synthesize a target, change a port contract, or un-omit dependent SA implementation records. Structural validation still rejects typed OA Take/Candidate proposals. Automatic/new-counterpart omissions remain unchanged.
Three regressions exercise Keep and Candidate retaining Legacy, unchanged SA source, typed history source/JSON round trips, typed OA proposals failing without mutation, and an explicit new Port counterpart remaining an SA-only omission. A separate read-only review found no scope/invariant defect. Focused red-to-green result: all three pass (`existing-port-green.log`). This change uses the common planner consumed by desktop and REST; it adds no endpoint or transport-specific behavior.
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
cargo test --locked --offline -p oa-core --test r3_semantic reverse_ -- --nocapture
cargo test --locked --offline -p oa-core
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
cargo fmt --all -- --check
```
All 359 Windows core tests pass, including all 34 semantic tests and existing portless-OA transition coverage. Strict core all-target Clippy and workspace formatting pass. Logs: `existing-port-core-test.log` and `existing-port-core-clippy.log` in the dedicated target. REST preview uses the corrected core planner at `oa-server/src/routes.rs` and commit uses the shared core prepare path; those handlers were inspected, not changed. GUI constructor tests and actual API/native acceptance remain with root integration; no native input or desktop code was changed here.
+57
View File
@@ -0,0 +1,57 @@
# Hosted desktop editing
Status: implementation, 24 focused tests, all 351 Windows desktop tests, strict desktop all-target Clippy, formatting and optimized build pass. Native and Linux integration acceptance remain with the lead. Base: 1086e54. The lead owns integration, Linux and native acceptance.
## Contract and ownership
The optional desktop adapter uses the existing hosted envelope and source-project REST routes. Open compiles and checks the coupled source/model; Publish creates a resource; Save uses the captured opaque ETag. Document remains the sole accepted local history. Complete authored bundles retain comments/includes and Undo stays local until an explicit save. No server, metamodel, persistence or general synchronization framework was added.
A hosted handle is separate from disk ownership. Successful publication/open attaches it; successful source Save As switches to local ownership. Failed saves and JSON exports preserve the active destination. Requests carry a unique ticket, desktop document identity, local revision and exact compiled snapshot. Stale replies are ignored and late receipts cannot mark newer edits saved or close them.
Conflicts keep local content and fetch a separate server review. Reload is one undoable accepted change. Readback can acknowledge an uncertain write only when the exact submitted bundle matches; publication is never automatically replayed. Known publication IDs survive lost follow-up reads. Review and reconciliation carry their own connection, including when publishing to a different server. Corrected credentials replace only matching-base handles.
Credentials are masked, redacted from Debug/errors and kept in memory. URLs reject credentials/query/fragments; remote connections require HTTPS, with HTTP supported on loopback. Redirects/retries are disabled. Request, response, source and time budgets are bounded. The implementation reuses already locked reqwest/Rustls dependencies.
Owned code: hosted/{mod,client,view}.rs, transport tests, app/hosted.rs and lifecycle tests; narrow App/main/Cargo hooks. The lead explicitly authorized the three views.rs header hooks: hosted status, Hosted models action and active Save label. Other shared UI/property/harness files were untouched.
## Focused evidence
Windows, dedicated target D:/CodexBuilds/Capella2-scenario-target:
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
cargo test --locked --offline -p oa-desktop hosted
```
24 tests pass: 13 transport tests and 11 lifecycle tests. Transport uses actual loopback sockets, production oa-server fixtures and bounded fault fixtures. The actual App test consumes Iced Tasks and routes real HTTP completions through App::update: Publish, graphical edit/Save, competing API edit/412, corrected credentials, direct review Reload, Undo/Save, uncertain-write Check, catalog/Open and Save & continue to New. These are runtime/model tests, not native GUI acceptance.
Fresh independent lifecycle review found stale captured credentials after reconnect. The production fix refreshes matching-base session, uncertain-write and review connections. The actual App regression fails when that refresh is removed and passes when restored; hosted-credentials-red.log records the expected failure. A restored-source timestamp initially reused Cargo's red binary; advancing the source modification time forced the verified rebuild. hosted-focused-test.log records the passing corrected suite. Reviewer re-review found no remaining production defect; its test-coverage suggestion was incorporated with separate direct Reload and uncertain Check paths.
A first build caught adapter visibility and test cursor API issues, both fixed. A network test originally required connection_failed for a closed Windows port; Windows can instead hit the bounded timeout. The assertion now accepts either certain read failure while checking that the result is not an uncertain mutation.
## Remaining acceptance
The final Windows desktop test run passed 351 tests; strict all-target Clippy and formatting passed. Clippy caught an unnecessary test-only PathBuf allocation, which was removed. Hosted inputs now show their disabled state while requests run. The optimized desktop build passed in 2m 46s. Freeze a separate native trial executable with commit/hash. The lead must exercise default/minimum Windows and Linux dialogs, publish/open, graphical and textual save, API artifact changes, conflict/reload/Undo, offline recovery and local Save As. Update guide/server/scope claims that hosted desktop editing is unavailable after integration; this worker does not own those shared documents. Hosted editing remains explicit save/reload, without automatic merges, server history or collaborative synchronization.
## Verified Windows artifact and commands
Production commit: `6fcc238c5240d35be5e2ef828b16fbf1bd01bdbd` (feature `53a509553daa297a8f602262d0498142010829d4`). The isolated worktree is `D:/CodexBuilds/Capella2-hosted-desktop` on `codex/hosted-desktop`.
From that checkout, the completed gates were:
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
cargo test --locked --offline -p oa-desktop
cargo clippy --locked --offline -p oa-desktop --all-targets -- -D warnings
cargo fmt --all -- --check
cargo build --locked --offline -p oa-desktop --release
```
The target contains `hosted-desktop-test.log` (351 passed, zero failures/ignored), `hosted-desktop-clippy.log`, `hosted-fmt.log` and `hosted-desktop-build.log`. The transport tests exercised local sockets only; no external deployment or native input occurred.
Frozen executable: `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-hosted-windows-6fcc238/arcadia-studio.exe`.
SHA-256: `51E4C02F1265D3E9B140C2BCF34A8B11E25E5B0D1D32C83E987FEA4BEB4FC061`.
The release output and frozen copy hashes match. A sibling `manifest.json` records the production commit, gates and pending acceptance. No existing trial artifact was replaced. The assigned scenario target and HOST index are idle and returned to the lead after this evidence checkpoint.