Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e80708677e | ||
|
|
12e3020c97 | ||
|
|
b04c4b37f4 | ||
|
|
798b277748 | ||
|
|
8a75294be2 | ||
|
|
43370de3ac | ||
|
|
f6a59de85b | ||
|
|
6fcc238c52 | ||
|
|
53a509553d | ||
|
|
1086e54ecc |
Generated
+6
@@ -2155,10 +2155,16 @@ dependencies = [
|
|||||||
name = "oa-desktop"
|
name = "oa-desktop"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"axum",
|
||||||
"iced",
|
"iced",
|
||||||
|
"iced_runtime",
|
||||||
"oa-core",
|
"oa-core",
|
||||||
|
"oa-server",
|
||||||
|
"reqwest",
|
||||||
"rfd",
|
"rfd",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ The normative [language and editing contract](docs/language.rst) defines support
|
|||||||
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
|
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
From the repository root:
|
From the repository root:
|
||||||
@@ -102,7 +102,7 @@ Reparenting a nested element through Properties moves its declaration, descendan
|
|||||||
|
|
||||||
## Save and reopen
|
## 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.
|
**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.
|
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
|
```sh
|
||||||
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
|
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
|
-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.
|
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-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.
|
- `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.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::catalog::{capture_observation, collection, is_constructor, replace_observation, sides};
|
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::mutate::*;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::model::*;
|
use crate::model::*;
|
||||||
@@ -297,16 +297,22 @@ fn apply_canonical_slot(
|
|||||||
let fact = replace_observation(fact, Layer::Sa, desired.clone())?;
|
let fact = replace_observation(fact, Layer::Sa, desired.clone())?;
|
||||||
write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false)
|
write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false)
|
||||||
}
|
}
|
||||||
fn target_only(selector: &FacetSelector, source: Layer, target: Layer) -> bool {
|
fn member_present(fact: &FacetReview, layer: Layer) -> bool {
|
||||||
match selector {
|
fn observed<T>(review: &Reviewed<T>, layer: Layer) -> &Observed<T> {
|
||||||
FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => {
|
match layer {
|
||||||
chosen(keys, source).is_none() && chosen(keys, target).is_some()
|
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) => {
|
FacetReview::Annotation { review, .. } => {
|
||||||
chosen(keys, source).is_none() && chosen(keys, target).is_some()
|
matches!(observed(review, layer), Observed::Value(Some(_)))
|
||||||
|
}
|
||||||
|
FacetReview::Binding { review, .. } => {
|
||||||
|
matches!(observed(review, layer), Observed::Value(Some(_)))
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@@ -320,15 +326,19 @@ fn preserve_constructor_members(
|
|||||||
source_layer: Layer,
|
source_layer: Layer,
|
||||||
target_layer: Layer,
|
target_layer: Layer,
|
||||||
) -> Result<(), String> {
|
) -> 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 {
|
for fact in initial_facts {
|
||||||
if !constructor_contains(group, &fact.selector)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let slot = target_slot(target, &fact.selector, target_layer)?;
|
let slot = target_slot(target, &fact.selector, target_layer)?;
|
||||||
let was_corresponding = baseline.facets.iter().any(|old| {
|
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())
|
&& target_slot(target, &old.selector(), target_layer).ok() == Some(slot.clone())
|
||||||
});
|
});
|
||||||
if was_corresponding {
|
if was_corresponding {
|
||||||
@@ -723,12 +733,28 @@ fn build_review(
|
|||||||
constructor_actions.push((target, selected.clone(), local_record));
|
constructor_actions.push((target, selected.clone(), local_record));
|
||||||
}
|
}
|
||||||
let refreshed = semantic_facets(observed, &trace, &references)?;
|
let refreshed = semantic_facets(observed, &trace, &references)?;
|
||||||
let group_selectors: BTreeSet<_> = refreshed
|
let mut group_selectors: BTreeSet<_> = refreshed
|
||||||
.iter()
|
.iter()
|
||||||
.chain(all.iter())
|
.chain(all.iter())
|
||||||
.map(|f| f.selector.clone())
|
.map(|f| f.selector.clone())
|
||||||
.filter(|f| constructor_contains(selected, f))
|
.filter(|f| constructor_contains(selected, f))
|
||||||
.collect();
|
.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![];
|
let mut group = vec![];
|
||||||
for selector in group_selectors {
|
for selector in group_selectors {
|
||||||
match capture_facet(observed, oa, sa, &selector) {
|
match capture_facet(observed, oa, sa, &selector) {
|
||||||
|
|||||||
@@ -395,11 +395,30 @@ pub fn plan_transition(
|
|||||||
commands: vec![],
|
commands: vec![],
|
||||||
};
|
};
|
||||||
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
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)
|
sa_port_omissions(project)
|
||||||
} else {
|
} else {
|
||||||
BTreeSet::new()
|
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();
|
let mut reported_omissions = BTreeSet::new();
|
||||||
if selected.len() != request.sources.len() {
|
if selected.len() != request.sources.len() {
|
||||||
conflict(
|
conflict(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,5 +13,14 @@ path = "src/main.rs"
|
|||||||
oa-core = { path = "../oa-core" }
|
oa-core = { path = "../oa-core" }
|
||||||
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
|
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
|
||||||
rfd = "0.15"
|
rfd = "0.15"
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
tokio = { version = "1", features = ["rt", "time"] }
|
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"] }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod hosted;
|
||||||
use crate::forms::Draft;
|
use crate::forms::Draft;
|
||||||
use crate::{
|
use crate::{
|
||||||
document::Document,
|
document::Document,
|
||||||
@@ -280,6 +281,7 @@ impl std::fmt::Display for Choice {
|
|||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum Pending {
|
pub enum Pending {
|
||||||
|
HostedOpen,
|
||||||
New,
|
New,
|
||||||
Demo,
|
Demo,
|
||||||
Open,
|
Open,
|
||||||
@@ -299,6 +301,7 @@ pub struct FilePrompt {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
|
Hosted(crate::hosted::Event),
|
||||||
PortAnchor(crate::port_edit::Event),
|
PortAnchor(crate::port_edit::Event),
|
||||||
Connection(crate::connection_edit::Event),
|
Connection(crate::connection_edit::Event),
|
||||||
Menu(crate::model_menu::Event),
|
Menu(crate::model_menu::Event),
|
||||||
@@ -410,6 +413,7 @@ pub enum Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
|
pub hosted: crate::hosted::State,
|
||||||
pub connection: Option<crate::connection_edit::Session>,
|
pub connection: Option<crate::connection_edit::Session>,
|
||||||
pub model_menu: Option<crate::model_menu::Opened>,
|
pub model_menu: Option<crate::model_menu::Opened>,
|
||||||
pub source_tree: crate::source_tree::State,
|
pub source_tree: crate::source_tree::State,
|
||||||
@@ -525,6 +529,7 @@ impl App {
|
|||||||
Kind::Activity
|
Kind::Activity
|
||||||
},
|
},
|
||||||
status: "Coastal rescue example · explore both engineering perspectives".into(),
|
status: "Coastal rescue example · explore both engineering perspectives".into(),
|
||||||
|
hosted: Default::default(),
|
||||||
busy: false,
|
busy: false,
|
||||||
pending: None,
|
pending: None,
|
||||||
delete_pending: false,
|
delete_pending: false,
|
||||||
@@ -844,7 +849,10 @@ impl App {
|
|||||||
let source_version = self.source_version + 1;
|
let source_version = self.source_version + 1;
|
||||||
let lint_running = self.lint_running;
|
let lint_running = self.lint_running;
|
||||||
let guide = std::mem::take(&mut self.guide);
|
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 = Self::with_project(project);
|
||||||
|
self.hosted = hosted;
|
||||||
self.window_size = window_size;
|
self.window_size = window_size;
|
||||||
self.canvas_size = canvas_size;
|
self.canvas_size = canvas_size;
|
||||||
self.source_version = source_version;
|
self.source_version = source_version;
|
||||||
@@ -906,8 +914,12 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn view(&self) -> Element<'_, Message> {
|
pub fn view(&self) -> Element<'_, Message> {
|
||||||
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
|
let base = crate::context_menu::Popup::new(
|
||||||
.into()
|
crate::views::view(self),
|
||||||
|
crate::model_menu::popup(self),
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
crate::hosted::view::overlay(self, base)
|
||||||
}
|
}
|
||||||
pub fn subscription(&self) -> Subscription<Message> {
|
pub fn subscription(&self) -> Subscription<Message> {
|
||||||
Subscription::batch([
|
Subscription::batch([
|
||||||
@@ -1019,6 +1031,7 @@ impl App {
|
|||||||
&& !matches!(
|
&& !matches!(
|
||||||
message,
|
message,
|
||||||
Message::Saved(_)
|
Message::Saved(_)
|
||||||
|
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||||
| Message::Loaded(_)
|
| Message::Loaded(_)
|
||||||
| Message::IncludesLoaded(_, _)
|
| Message::IncludesLoaded(_, _)
|
||||||
| Message::SavePath(_)
|
| Message::SavePath(_)
|
||||||
@@ -1038,6 +1051,10 @@ impl App {
|
|||||||
message,
|
message,
|
||||||
Message::Escape | Message::Request(Pending::Close(_))
|
Message::Escape | Message::Request(Pending::Close(_))
|
||||||
) {
|
) {
|
||||||
|
if self.hosted.visible {
|
||||||
|
self.hosted.visible = false;
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
if self.workflow.take().is_some() {
|
if self.workflow.take().is_some() {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
@@ -1062,6 +1079,7 @@ impl App {
|
|||||||
| Message::Lint(_)
|
| Message::Lint(_)
|
||||||
| Message::Linted(_, _)
|
| Message::Linted(_, _)
|
||||||
| Message::Saved(_)
|
| Message::Saved(_)
|
||||||
|
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||||
| Message::Loaded(_)
|
| Message::Loaded(_)
|
||||||
| Message::IncludesLoaded(_, _)
|
| Message::IncludesLoaded(_, _)
|
||||||
| Message::SavePath(_)
|
| Message::SavePath(_)
|
||||||
@@ -1069,7 +1087,8 @@ impl App {
|
|||||||
| Message::Exported(_)
|
| Message::Exported(_)
|
||||||
);
|
);
|
||||||
if !modal_event
|
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()
|
|| (self.file_prompt.is_some()
|
||||||
&& !matches!(
|
&& !matches!(
|
||||||
message,
|
message,
|
||||||
@@ -1187,6 +1206,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||||
match message {
|
match message {
|
||||||
|
Message::Hosted(event) => return self.hosted_event(event),
|
||||||
Message::ScenarioCanvas(event) => {
|
Message::ScenarioCanvas(event) => {
|
||||||
use crate::behavior::scenario::{self, CanvasEvent};
|
use crate::behavior::scenario::{self, CanvasEvent};
|
||||||
let context = event.context();
|
let context = event.context();
|
||||||
@@ -2490,6 +2510,9 @@ impl App {
|
|||||||
return self.handle(Message::Save(false));
|
return self.handle(Message::Save(false));
|
||||||
}
|
}
|
||||||
Message::Save(save_as) => {
|
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() {
|
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
|
||||||
self.continue_after_save = false;
|
self.continue_after_save = false;
|
||||||
self.pending = None;
|
self.pending = None;
|
||||||
@@ -2541,6 +2564,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.disk_project = Some(disk);
|
self.disk_project = Some(disk);
|
||||||
self.path = Some(receipt.path);
|
self.path = Some(receipt.path);
|
||||||
|
self.hosted.session = None;
|
||||||
|
self.hosted.review = None;
|
||||||
|
self.hosted.uncertain = None;
|
||||||
self.document.mark_saved(receipt.snapshot);
|
self.document.mark_saved(receipt.snapshot);
|
||||||
self.prune_closed_files(!self.document.dirty());
|
self.prune_closed_files(!self.document.dirty());
|
||||||
self.status = "All source files saved".into();
|
self.status = "All source files saved".into();
|
||||||
@@ -2879,6 +2905,7 @@ impl App {
|
|||||||
fn proceed(&mut self, pending: Pending) -> Task<Message> {
|
fn proceed(&mut self, pending: Pending) -> Task<Message> {
|
||||||
self.pending = None;
|
self.pending = None;
|
||||||
match pending {
|
match pending {
|
||||||
|
Pending::HostedOpen => self.open_hosted(),
|
||||||
Pending::New => {
|
Pending::New => {
|
||||||
self.reset(Project::empty(), None);
|
self.reset(Project::empty(), None);
|
||||||
Task::none()
|
Task::none()
|
||||||
@@ -3037,6 +3064,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
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_document = Id::new_v4();
|
||||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||||
self.path = Some(path);
|
self.path = Some(path);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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(¤t, &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());
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ impl Topic {
|
|||||||
Self::Source => "Textual editing & Git",
|
Self::Source => "Textual editing & Git",
|
||||||
Self::Recovery => "Checks & error recovery",
|
Self::Recovery => "Checks & error recovery",
|
||||||
Self::Files => "Files & keyboard shortcuts",
|
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 => {
|
Topic::Server => {
|
||||||
const {
|
const {
|
||||||
Article {
|
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",
|
keywords: "server rest api automation ai plugin curl http etag if-match revision resource_id deploy hosted cad scripting openapi token",
|
||||||
sections: &[
|
sections: &[
|
||||||
example(
|
example(
|
||||||
"Start a local service",
|
"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.",
|
"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.",
|
||||||
"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",
|
"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(
|
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.",
|
"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.",
|
"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.",
|
"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(
|
explain(
|
||||||
"Deployment and persistence",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ mod explorer_view;
|
|||||||
mod forms;
|
mod forms;
|
||||||
mod guide;
|
mod guide;
|
||||||
mod highlight;
|
mod highlight;
|
||||||
|
mod hosted;
|
||||||
mod model_menu;
|
mod model_menu;
|
||||||
mod navigation;
|
mod navigation;
|
||||||
mod port_edit;
|
mod port_edit;
|
||||||
|
|||||||
@@ -55,22 +55,14 @@ pub fn view(app: &App) -> View<'_, Message> {
|
|||||||
]
|
]
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.width(220),
|
.width(220),
|
||||||
column![
|
column![text(short(&p.name, 46)).size(17), label(app.hosted_label())]
|
||||||
text(short(&p.name, 46)).size(17),
|
.spacing(3)
|
||||||
label(if app.dirty() {
|
.width(Fill),
|
||||||
"Unsaved changes"
|
|
||||||
} else if app.path.is_some() {
|
|
||||||
"All changes saved"
|
|
||||||
} else {
|
|
||||||
"Local project"
|
|
||||||
})
|
|
||||||
]
|
|
||||||
.spacing(3)
|
|
||||||
.width(Fill),
|
|
||||||
action("New", Message::Request(Pending::New)),
|
action("New", Message::Request(Pending::New)),
|
||||||
|
crate::hosted::view::header_action(),
|
||||||
action("Open", Message::Request(Pending::Open)),
|
action("Open", Message::Request(Pending::Open)),
|
||||||
action("Save as", Message::Save(true)),
|
action("Save as", Message::Save(true)),
|
||||||
button(text("Save all").size(12))
|
button(text(app.save_label()).size(12))
|
||||||
.padding([10, 16])
|
.padding([10, 16])
|
||||||
.on_press(Message::Save(false))
|
.on_press(Message::Save(false))
|
||||||
]
|
]
|
||||||
|
|||||||
+4
-4
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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.
|
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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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
|
## Remaining boundaries
|
||||||
|
|
||||||
- Logical and Physical Architecture, deployment semantics, executable behavior, complete SysML or Capella metamodel support, and native `.capella`/`.aird` import/export.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -27,12 +27,13 @@ forms, folding, placement and correct camera/tool lifetime. Windows default/mini
|
|||||||
and Linux native acceptance plus independent reviews are recorded in
|
and Linux native acceptance plus independent reviews are recorded in
|
||||||
release-state-editing.md. Direct scenario/process gestures remain required.
|
release-state-editing.md. Direct scenario/process gestures remain required.
|
||||||
|
|
||||||
R6 data contracts are active in the isolated data integration checkout. Freeze the
|
R6 data contracts and direct scenario/process gestures are integrated; native
|
||||||
full payload/dictionary shape as schema/source4, keep existing typed ports, migrate
|
acceptance continues on both platforms. Typed R3 core/source5/REST and scalar review
|
||||||
older formats explicitly while preserving lexical ownership/comments, then verify
|
milestones1–2 are integrated and pass full platform checks. Close independent scalar
|
||||||
GUI/text/REST parity and real engineering workflows. See release-data-contract.md
|
fixes and native review, then finish constructor/keyed/order/reset/artifact and
|
||||||
and the implementation plan in that checkout. R3 semantic drift/reconciliation
|
multi-source graphical authoring using the existing shared core planner. Source5
|
||||||
follows the R6 freeze; its current prose-only behavior is insufficient.
|
migration retains lexical ownership/comments; no flat source rewrite is allowed.
|
||||||
|
See release-data-contract.md, release-semantic-integration.md and release-semantic-gui.md.
|
||||||
|
|
||||||
Continue source authoring improvements only through the production parser/updater.
|
Continue source authoring improvements only through the production parser/updater.
|
||||||
The23-test experiment is evidence, not architecture: independent probes exposed
|
The23-test experiment is evidence, not architecture: independent probes exposed
|
||||||
|
|||||||
+132
-108
@@ -1,128 +1,152 @@
|
|||||||
# First release — current engineering state
|
# First release — current engineering state
|
||||||
|
|
||||||
## Objective and current phase
|
## Objective and phase
|
||||||
|
|
||||||
The first-release goal remains active and incomplete: usable Rust/Iced OA/SA,
|
The first-release goal is active and incomplete: usable Rust/Iced OA/SA,
|
||||||
equivalent graphical/source/REST semantics, artifact traceability, direct behavior
|
equivalent graphical/source/REST semantics, traceability, direct behavior editing,
|
||||||
editing, offline guidance, lifecycle/hosted workflows and Windows/Linux evidence.
|
offline guidance, lifecycle/hosted workflows and Windows/Linux evidence.
|
||||||
Authoritative objective reread2026-09-06: attachment10134df9-bea2-4c02-a820-67b53d6b86f7.
|
Authoritative objective reread 2026-09-06: attachment10134df9-bea2-4c02-a820-67b53d6b86f7.
|
||||||
PLAN retains full scope. No subsystem checkpoint completes the release.
|
PLAN retains full scope; no subsystem checkpoint completes the release.
|
||||||
|
|
||||||
Main is4101434 (documentation pointer on productiona8f451e). Active integration is
|
Main remains a documentation pointer on production a8f451e. Current integration is
|
||||||
D:/CodexBuilds/Capella2-data-integration, branchcodex/data-contract-integration,
|
D:/CodexBuilds/Capella2-data-integration, branch codex/data-contract-integration.
|
||||||
production merge60d162c. Root owns this checkout and native input. Its target is
|
Production merge332e46b + formatc6db9d4 + evidenced114a5d integrate DATA/scenario/
|
||||||
D:/CodexBuilds/Capella2-data-target. Linux verification checkout is
|
process60d162c and semantic6ad3cb. Scalar fixes5a51a75 integrated as e7fa061 and evidence767634a;
|
||||||
D:/CodexBuilds/Capella2-data-linux-review at60d162c, targetCapella2-data-linux-target.
|
322 desktop tests/strict checks/build pass on both platforms. That worker owns DATA/Linux indexes and targets;
|
||||||
Both targets are currently free; frozen native binaries are separate from targets.
|
root owns this file, other native ledgers, README and all native input.
|
||||||
|
|
||||||
## Previously accepted foundations
|
## Accepted foundations and evidence
|
||||||
|
|
||||||
- Typed OA/SA core, atomic commands/history and explicit typed port migration:
|
- Typed ports and migration: release-port-{contract,core,migration}.md and
|
||||||
release-port-{contract,core,migration}.md and release-capella-workflow-benchmark.md.
|
release-capella-workflow-benchmark.md.
|
||||||
- Explorer grouping/deep hierarchy, contextual port creation, authored routing,
|
- Explorer grouping/deep hierarchy, contextual port creation, routing, labels,
|
||||||
movable labels/anchors, direct exchange drawing, context menus and source tree:
|
anchors, direct exchange drawing, menus and source tree: release-explorer-usability.md,
|
||||||
release-explorer-usability.md, release-routing-{verification,linux}.md,
|
release-routing-{verification,linux}.md, release-port-drawing.md,
|
||||||
release-port-drawing.md and release-context-menus.md.
|
release-context-menus.md.
|
||||||
- Modular source/includes with provenance-aware writes, exact source/model history,
|
- Modular source/includes, provenance-aware writes, exact source/model history,
|
||||||
retained drafts, recovery and coupled hosted revisions: release-source-integration.md.
|
retained drafts and coupled hosted revisions: release-source-integration.md.
|
||||||
- Direct state creation/movement/transitions/reconnect, typed forms, folding and
|
- Direct state creation/movement/transitions/reconnect, typed forms, fold and
|
||||||
per-view camera/tool behavior, accepted through316dc55 on Windows/Linux:
|
camera/tool lifetime through316dc55, Windows/Linux native: release-state-editing.md.
|
||||||
release-state-editing.md. Earlier full551/554 workspace and strict checks passed;
|
|
||||||
final scoped241 desktop tests/Clippy/build passed on both platforms.
|
|
||||||
|
|
||||||
## Active integrated DATA/scenario/process work
|
## Integrated semantics and verification
|
||||||
|
|
||||||
R6 core/source/API contracts and bounded GUI are integrated with direct scenario
|
R6 data payloads/defaults/inheritance/associations/interface allocations/qualified
|
||||||
and process editors. Data payloads, defaults, inheritance, associations, identified
|
message-value paths are integrated. Source3 migration preserves comments/nesting
|
||||||
interface allocations and qualified message-value paths use source/schema4.
|
until Save All. Typed review now uses source/schema5 with explicit migration.
|
||||||
Source3 native migration preserves comments/nesting until explicit Save All.
|
See release-data-{contract,implementation,verification}.md.
|
||||||
Evidence: release-data-{contract,implementation,verification}.md.
|
|
||||||
|
|
||||||
ab661f2 full Windows607/Linux610 workspace tests and strict workspace Clippy passed
|
Semantic core supports finite typed facts, historical references, mappings/reset,
|
||||||
(one explicit inheritance benchmark ignored per platform). Actual42 server tests
|
constructor groups, canonical agreement and exact prepared impacts. Independent
|
||||||
and OpenAPI/reST checks passed at R6. Later Windows native data editing, invalid
|
equal-discriminator review found and fixed first acknowledgment reachability:
|
||||||
default repair, allocation edits, recursive qualified values, Undo/Redo and clean
|
initial equal unreviewed discriminators acknowledge only that scalar; true and
|
||||||
reopen passed at1440x900 and1100x700. Bounded one-record property browser b35dede
|
historical switches retain grouped rules.353 core tests and independent36 focused/
|
||||||
passed264 desktop tests/Clippy/build on both platforms and Windows native retest.
|
8 adversarial probes passed. Normative clarification6ad3cb is integrated.
|
||||||
Linux native R6 acceptance is now underway, not yet passed.
|
|
||||||
|
|
||||||
Direct scenarios: middle and ALT-operand Call/Reply creation, qualified values,
|
REST query/preview/apply is implemented and verified after explicit user approval.
|
||||||
valid/invalid reorder, selection, scroll/Escape cancellation, pending-draft guards,
|
Exact prepared candidates preserve generated identities, conditional ETags and
|
||||||
Undo/Redo/save/reopen have Windows native evidence. c6c07cc fixes mode feedback,
|
coupled model/source commits. Fingerprints express consistency, not authentication.
|
||||||
occurrence-first labels and pinned Create/Cancel; normal/compact native passed.
|
48 server tests pass; optimized live loopback checks validate184 schemas and19
|
||||||
ae1602a stabilizes the canvas widget position across palette/form replacement.
|
actual/typed values, reject9 malformed shapes. Strict reST/catalog checks pass.
|
||||||
Root native lower-operand drag opened a form retaining that viewed guard/call;
|
No deployment was requested or performed. See release-semantic-integration.md.
|
||||||
Cancel restored the same region and clean model. Logs empty, exec86766 exited0.
|
|
||||||
Full evidence and outstanding ALT/reuse/resize/save-recovery tasks:
|
|
||||||
release-scenario-editing.md. A62-object guarded model is saved in the trial copies.
|
|
||||||
|
|
||||||
Direct processes: production8bc564b + evidence1a3164b merged into60d162c. Existing
|
d114a5d integration: Windows718 / Linux721 tests pass, one intentional inheritance
|
||||||
work/exchange/reuse/control contributions, guarded exact-link insertion, reconnect,
|
benchmark ignored on each. Strict workspace all-target Clippy, formatting and
|
||||||
peer reorder, bounded pickers and reusable-source navigation are implemented.
|
optimized desktop/server builds pass. Linux exec80687 exited0; logs are
|
||||||
Independent Exchange-first continuation defect fixed with explicit Append exchange;
|
semantic-workspace-test.log, semantic-workspace-clippy.log and
|
||||||
focused recheck closed. Worker305 desktop tests,19 core checks and strict Clippy/
|
semantic-release-build.log in Capella2-data-linux-target.
|
||||||
build passed. Native Windows/Linux process acceptance has not begun. See
|
|
||||||
release-process-editing.md; cross-reference inner links/disconnected exchange-only
|
|
||||||
topology remain explicit current-model boundaries, not full Capella parity claims.
|
|
||||||
|
|
||||||
Combined60d162c: Windows306 desktop tests, strict all-target Clippy and optimized
|
Scalar GUI milestones1–2 provide existing-trace scoped Keep/Take/Candidate review,
|
||||||
build passed (exec44082 exit0); Linux306/Clippy/release passed (exec83320 exit0).
|
typed forms, exact ordinary Batch, impact review and source/history. Independent
|
||||||
Frozen artifacts under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
review found three P2 issues: unrelated invalid draft blocking scoped actions,
|
||||||
- ui-behavior-windows-60d162c/arcadia-studio.exe SHA256
|
combined type-decision form context, and hidden historical keyed identities.
|
||||||
0CC1097701B2D750DBF7E50F5CF02F7F7D8F96476047569D0BD684D60BBF4379
|
Worker5a51a75 fixes all three;277 desktop tests/Clippy/build pass in SEM. Fresh
|
||||||
- ui-behavior-linux-60d162c/arcadia-studio SHA256
|
independent recheck passed280 tests (277 repository +3 independent probes),
|
||||||
8CDA9215FBD0B653CD6FB81F3884BF891BF931C659E2B926863134BDD4C43B5A
|
closing all three P2 findings; integrated platform verification also passed. Native scalar
|
||||||
Both have model/project.arcadia copied from the saved62-object Windows trial.
|
acceptance and structured milestone3 are still required; do not claim full parity.
|
||||||
|
|
||||||
## Isolated semantic review work and important new finding
|
## Native behavior evidence and current environment
|
||||||
|
|
||||||
D:/CodexBuilds/Capella2-semantic-review, branchcodex/semantic-review, checkpoint
|
Frozen60d162c binaries live under D:/CodexBuilds/Capella2-release1-01a06e2e:
|
||||||
d130013. Core346fb24 passed345 core tests (one intentional ignored probe), strict
|
ui-behavior-windows-60d162c and ui-behavior-linux-60d162c. Hashes in the linked ledgers.
|
||||||
Clippy and independent nine-finding recheck. Source5 persists finite typed facts,
|
Windows prior scenario native passed middle/ALT insertion, guarded correlated
|
||||||
historical references, mappings/reset, constructor groups, canonical slot agreement
|
Call/Reply, qualified values, Undo/Redo, save/reopen, minimum layout and viewport
|
||||||
and exact prepared impacts. Normative spec02f99a9 plus clarification29df024 passed
|
retention. See release-scenario-editing.md. Remaining advanced tasks are explicit.
|
||||||
strict generation,7 valid/11 invalid example checks and12 source/migration tests.
|
|
||||||
|
|
||||||
User explicitly reconfirmed REST after automatic approval review rejected scope
|
Linux1440x900 Wayland native: numeric Retry budget0..4 rejects default9 without
|
||||||
twice. The retry succeeded. d130013 implements typed query/preview/apply with
|
losing draft; default2 saves, Undo/Redo works. Recursive Destination/Next/Latitude
|
||||||
source-valid no-write preview, stable generated identities and ETag/base/fingerprint
|
browser preserves independent Origin expression; save/reopen passed. Created
|
||||||
checks. All48 server tests/strict Clippy pass;184 schemas,19 actual/typed values
|
Validated route Call/Reply in the middle, Undo/Redo and save/reopen passed. Created
|
||||||
against served/local contracts,9 invalid shapes rejected. Independent optional-
|
SA chain with two existing functions, drew guarded ready link, inserted existing
|
||||||
input schema mismatch fixed/rechecked. API work is committed; no deployment done.
|
Navigation request into it, verified guard and original link ID, Undo/Redo and saved
|
||||||
|
69objects/7links/3expected checks. Final chain reopen and advanced process cases
|
||||||
|
remain. See release-data-verification.md and release-process-editing.md.
|
||||||
|
|
||||||
New GUI design investigation reproduced a core coherence defect: initially equal
|
Wayland exec61870 closed normally, exit0 and no serious runtime output. Current Windows semantic trial is exec62786/window2099614, frozen e7fa061,
|
||||||
never-reviewed constructors cannot be acknowledged, leaving review permanently
|
1100x700. Root owns input. Re-inventory; never reuse closed window4852212.
|
||||||
Unreviewed after all leaves are accepted. constructor_ack_review now owns a narrow
|
Original Capella395654 remains available. Do not modify protected user windows.
|
||||||
core/spec fix: first-time equal discriminator acknowledgment only, no descendant
|
Wayland compact resizing is unverified: capture separates frame and client into
|
||||||
group acknowledgment; true/historical switches retain groups and repeat rejection.
|
bounded surfaces. X11 launch fails in dependency xkbcommon-dl because Ubuntu lacks
|
||||||
No public schema/request change planned. Core targetsemantic-target is worker-owned.
|
libxkbcommon-x11-0. sudo installation requires the user's password; user has been
|
||||||
semantic_gui_design owns desktop milestone1–2 and new release-semantic-gui.md in
|
given `sudo apt-get install libxkbcommon-x11-0`. Windows/Wayland testing continues.
|
||||||
SEM, with dedicated semantic-desktop-target: existing-pair grouped review, exact
|
Do not bypass authentication or treat this optional environment issue as goal blocked.
|
||||||
scalar Keep/Take/Candidate batch and source/history. Structured groups/keyed facts/
|
|
||||||
multi-source/reset/artifacts remain milestone3. Root has not integrated source5 yet.
|
|
||||||
|
|
||||||
## Runtime, environment and next actions
|
## Exact next actions
|
||||||
|
|
||||||
User reauthorized Computer Use; it works on Windows and WSLg. Root exclusively
|
1. Finish5a51a75 integration/recheck, update immutable binaries/hashes, operate scalar
|
||||||
operates native apps. Current Linux trial exec61870, WSLg window4852212, binary
|
review in actual Windows/Linux app including reverse, failure and compact forms.
|
||||||
ui-behavior-linux-60d162c. Initial render and model-path entry work; reobserve before
|
2. Complete native R6/scenario/process cases (both layers, branching/reuse/reconnect,
|
||||||
input. Window title has WSLg COPY MODE warning; app stderr must be checked before
|
cancellation, minimum size and source reopen); fix from observed behavior.
|
||||||
attributing it to application code. Protected old user windows7210312/6228978 were
|
3. Complete structured semantic GUI: constructor groups, keyed/order/reset/artifact
|
||||||
absent; do not modify them if they return. Original Capella395654 remains available.
|
operations and multi-source final-candidate review; retain shared core planner.
|
||||||
|
4. Finish coverage/filter, Home/recent/recovery, hosted desktop ETag and offline help
|
||||||
|
requirements. Fresh end-state reviews, full platform/MSRV checks and packaging.
|
||||||
|
|
||||||
1. Complete Linux R6/scenario native tasks and integrated Windows/Linux process
|
Use dedicated Cargo targets. Windows: cargo test --locked --offline --workspace;
|
||||||
tasks, including compact layouts, source save/reopen, failure and cancellation.
|
cargo clippy --locked --offline --workspace --all-targets -- -D warnings;
|
||||||
2. Finish/review equal-discriminator correction; finish scalar GUI and structured
|
cargo build --locked --offline --release -p oa-desktop -p oa-server.
|
||||||
GUI milestone3. Integrate source5/core/API/spec into DATA; full platform checks.
|
WSL Ubuntu24.04 uses target/linux-cargo.toml, Rust1.98.1 at
|
||||||
3. Complete remaining coverage/filters, Home/recent/recovery, hosted ETag desktop
|
/home/sorokinoleg/.cache/arcadia/rust/bin and Mesa software Vulkan. No remote push,
|
||||||
workflows, offline help and Capella semantic gaps identified in PLAN.
|
publication, usage reset or broad recursive target deletion is authorized.
|
||||||
4. Fresh cross-disciplinary end-state review, full Windows/Linux/MSRV/build/runtime
|
|
||||||
verification and reproducible packaging. Update main only with accepted work.
|
|
||||||
|
|
||||||
Meaningful native, implementation and semantic progress continues; no goal blocked
|
## Latest native and process checkpoint — 2026-09-06
|
||||||
or complete status is justified. No remote push/publication/usage reset authorized.
|
|
||||||
No broad recursive target deletion. Use distinct targets and frozen runtime copies.
|
Process cf33785 + evidence1b0b072 fixes forced zoom on palette changes and canvas
|
||||||
Windows checks: cargo test --locked --offline --workspace; cargo clippy --locked
|
remount/scroll loss on pending forms, with persistent two-path field labels.
|
||||||
--offline --workspace --all-targets -- -D warnings; cargo build --locked --offline
|
325 desktop tests/strict checks/release pass on each platform. Independent24-test
|
||||||
-p oa-desktop -p oa-server. WSL Ubuntu24.04 uses target/linux-cargo.toml, Rust1.98.1
|
review closed both original defects but found owner-to-owner Scrollable leakage.
|
||||||
in /home/sorokinoleg/.cache/arcadia/rust/bin and Mesa lavapipe for native WSLg.
|
Fix8c60008 resets actual observer child only on (document,owner) changes;25 focused
|
||||||
|
red/green checks pass. Full platforms and fresh semantic_http_review are active.
|
||||||
|
semantic_integration owns DATA/Linux indexes and targets for this narrow follow-up.
|
||||||
|
|
||||||
|
Windows60d162c reopened Linux69-object bundle. At1100x700, inserted named Choice
|
||||||
|
Route available? on the ready path, then Two paths added Choice/two existing-work
|
||||||
|
occurrences/Merge with ready/else guards. Undo74→70 and Redo70→74; Save All wrote
|
||||||
|
74objects/13links/5 incomplete-model checks. Original link identity/ready preserved.
|
||||||
|
Trial3607004 closed; saved bundle remains in ui-behavior-linux-60d162c/model.
|
||||||
|
Final camera/owner fixes and advanced OA/process native cases remain unverified.
|
||||||
|
|
||||||
|
Scalar Windows e7fa061 actual native: source5 fixture, numeric prior/current
|
||||||
|
comparison; Keep SA minimum1, Take OA maximum20, candidate default99 rejects and
|
||||||
|
retains all three decisions; corrected7. Error, editor and Apply fit1100x700.
|
||||||
|
Additional changes0; Apply atomically changes SA to1..20/default7, OA remains
|
||||||
|
0..20/default5. Native text verifies comment and values. Undo restores saved state,
|
||||||
|
Redo restores review; Save All succeeds. Field Type Take OA Boolean + Default
|
||||||
|
Candidate creates Boolean(false), previews/applies/saves successfully at1100x700.
|
||||||
|
Current saved working copy: C:/Users/sorok/AppData/Local/Temp/
|
||||||
|
capella2-semantic-native-fixture-20260906/windows-scalar.arcadia. Runtime stderr empty.
|
||||||
|
Reverse, mapping, historical identity, reopen and Linux scalar cases remain.
|
||||||
|
|
||||||
|
semantic_gui_design owns SEM message-kind/TimerExpression constructor slice and
|
||||||
|
compact Traceability toolbar clipping fix (views.rs), plus its semantic module/forms
|
||||||
|
adapter/GUI ledger and semantic-desktop-target. Scope is one existing trace and a
|
||||||
|
complete nested constructor family; no API/core change or full parity claim.
|
||||||
|
semantic_http_review temporarily owns semantic-desktop-target for isolated process
|
||||||
|
review; GUI worker must wait for release before builds. Root owns all native input.
|
||||||
|
|
||||||
|
Latest process closure: DATA/Linux f4cb486, production8c60008.327 desktop tests,
|
||||||
|
strict Clippy/fmt/build pass on both. Independent26 tests (4 actual Iced observer,
|
||||||
|
22 process) close owner-scroll findings. Both targets are idle and root-owned.
|
||||||
|
Frozen ui-process-owner-{windows,linux}-8c60008 await root native verification.
|
||||||
|
semantic_http_review used a separate Temp target and released it; SEM GUI owns
|
||||||
|
semantic-desktop-target exclusively. Hosted lifecycle mapping is read-only parallel
|
||||||
|
work by semantic_integration; no shared index or target reservation for that task.
|
||||||
|
|||||||
@@ -86,3 +86,15 @@ validation and independent review after direct user confirmation resolved approv
|
|||||||
New constructor-acknowledgment reachability defect was independently reproduced;
|
New constructor-acknowledgment reachability defect was independently reproduced;
|
||||||
narrow core fix and separate scalar GUI implementation are active in SEM. These
|
narrow core fix and separate scalar GUI implementation are active in SEM. These
|
||||||
are current unresolved work, not accepted release limitations. See current STATE.
|
are current unresolved work, not accepted release limitations. See current STATE.
|
||||||
|
|
||||||
|
## Current evidence superseding earlier in-progress notes — 2026-09-06
|
||||||
|
|
||||||
|
Semantic source5/core/REST and scalar GUI are integrated at d114a5d. Full Windows718/
|
||||||
|
Linux721 tests, strict Clippy, optimized builds and live semantic REST contracts pass.
|
||||||
|
The equal initial discriminator acknowledgment defect is fixed and independently
|
||||||
|
closed. Three scalar GUI P2 fixes5a51a75 are under fresh recheck/integration; structured
|
||||||
|
authoring remains required. Basic Linux R6, middle scenario and guarded chain insertion
|
||||||
|
have actual native evidence in their ledgers; advanced and compact cases remain.
|
||||||
|
X11 lacks libxkbcommon-x11-0, installation requires user's sudo password. User has
|
||||||
|
the exact fix command; Windows/Wayland progress is independent. This environment
|
||||||
|
issue is not evidence of a semantic defect or reason to mark the release blocked.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -135,3 +135,18 @@ launched in WSLg but capture timed out awaiting app access. No Linux native acce
|
|||||||
The user subsequently stopped Computer Use with Escape on the combined Windows
|
The user subsequently stopped Computer Use with Escape on the combined Windows
|
||||||
scenario build's initial capture. All native input stopped; code integration/gates
|
scenario build's initial capture. All native input stopped; code integration/gates
|
||||||
continue. R6 is not accepted until Linux native gates close.
|
continue. R6 is not accepted until Linux native gates close.
|
||||||
|
|
||||||
|
## Root Linux native data trial — 2026-09-06
|
||||||
|
|
||||||
|
Frozen60d162c, Wayland1440x900, ui-behavior-linux-60d162c/model/project.arcadia.
|
||||||
|
ELF SHA2568CDA9215FBD0B653CD6FB81F3884BF891BF931C659E2B926863134BDD4C43B5A.
|
||||||
|
Created Retry budget (7fa58d09-f9b4-4ab9-9114-1cadc67bf6e9), Integer0..4.
|
||||||
|
Default9 was rejected with invalid_data_default and retained the draft; correction2
|
||||||
|
saved to the new included sa/types.arcadia. Undo/Redo restored the entire edit.
|
||||||
|
Original Navigation request call8888395e retained Origin/Next/Latitude expression
|
||||||
|
origin.next.latitude +0.5. Destination paging stayed clean and retained its value;
|
||||||
|
browsed Next coordinate, selected Latitude and saved destination.next.latitude +0.25.
|
||||||
|
Exact source contains both independent qualified paths. Native reopen loaded clean65
|
||||||
|
objects after the scenario additions, including Retry budget and recursive values.
|
||||||
|
No Linux association/allocation trial or compact layout acceptance is claimed.
|
||||||
|
Wayland process61870 subsequently closed0 without serious runtime output.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -298,3 +298,14 @@ pass306 desktop tests, strict all-target Clippy and optimized build. New frozen
|
|||||||
ui-behavior-{windows,linux}-60d162c artifacts have copied62-object models. Linux
|
ui-behavior-{windows,linux}-60d162c artifacts have copied62-object models. Linux
|
||||||
exec61870/window4852212 launched and is now under root native test; acceptance
|
exec61870/window4852212 launched and is now under root native test; acceptance
|
||||||
remains pending. Full release scope is unchanged.
|
remains pending. Full release scope is unchanged.
|
||||||
|
|
||||||
|
## Root Linux native scenario trial — 2026-09-06
|
||||||
|
|
||||||
|
Frozen60d162c Wayland1440x900. Native drag in the gap after old reply6 and before
|
||||||
|
Navigation outcome ALT opened Call+correlated Reply while preserving that region.
|
||||||
|
Named Validated route and chose existing Navigation request. Create63→65, Undo65→63,
|
||||||
|
Redo63→65; execution bar ends at its reply. Save All and native reopen passed.
|
||||||
|
Call ef0ef967-041b-4bf2-86b8-977047a3ca90 and correlated reply
|
||||||
|
0005a94e-49c1-41e2-82f9-5a6f285dc604 appear before ALT in saved steps.
|
||||||
|
Model: ui-behavior-linux-60d162c/model/project.arcadia. Linux ALT editing/reuse/
|
||||||
|
reorder/cancellation/minimum layout remain required. Wayland61870 closed0 normally.
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Semantic review native evidence
|
||||||
|
|
||||||
|
This is partial acceptance, not release completion. Windows frozen e7fa061,
|
||||||
|
SHA2563E46F001711948FB60E3F65C49810DF5C177228D5DFA609ED9DF3F8C96081F34.
|
||||||
|
Trial exec62786/window2099614; stderr/stdout captured under ui-semantic-windows-e7fa061.
|
||||||
|
Source fixture and exact GUI steps: C:/Users/sorok/AppData/Local/Temp/
|
||||||
|
capella2-semantic-native-fixture-20260906. Original source SHA256
|
||||||
|
54BA9528F6904428B3DE2E41E3D8CAC6861FA652E5F83F11C1516FB3DB40E937.
|
||||||
|
Root edits only windows-scalar.arcadia, preserving the generator's starting source.
|
||||||
|
|
||||||
|
## Observed Windows workflows
|
||||||
|
|
||||||
|
At1440x900, opened source5,9objects/0links/2 expected pending-prose checks.
|
||||||
|
Traceability range card shows prior bounds0..100/default10 and current OA0..20/5,
|
||||||
|
SA1..30/15. Selected Keep System minimum, Take Operational maximum and final default.
|
||||||
|
Invalid99 rejects with invalid_data_default and disables Apply without losing input
|
||||||
|
or any decision. Resized actual window to1100x700; complete error/editor/actions fit.
|
||||||
|
Repair7 restores valid preview. Additional changes reports0. Apply changes SA to
|
||||||
|
1..20/default7 and preserves OA0..20/default5. Native text inspection shows exact
|
||||||
|
values and leading comment. One Undo returns All changes saved, Redo returns the
|
||||||
|
review, and Save All writes source. Disk inspection matches the native result.
|
||||||
|
|
||||||
|
At1100x700, Enabled flag Type Take Operational proposes Boolean over original
|
||||||
|
System Integer. Default Set final value then Set literal default produces the
|
||||||
|
Boolean checkbox/false value directly. Validated preview and Apply succeed; Save
|
||||||
|
All persists it. This exercises combined type context through actual controls.
|
||||||
|
|
||||||
|
## Remaining checks and findings
|
||||||
|
|
||||||
|
Reverse decisions, raw invalid bound scoped clearing, named-type mapping, deleted
|
||||||
|
historical identities, saved reopen and Linux remain required. Error evidence above
|
||||||
|
covers a normal validation message, not arbitrarily large diagnostics.
|
||||||
|
Native found compact Traceability toolbar clips Artifact after the semantic button;
|
||||||
|
semantic_gui_design owns a narrow wrapping correction. No scalar-modal defect found
|
||||||
|
in the tested paths. Structured review is a separate incomplete milestone.
|
||||||
Reference in New Issue
Block a user