Add optional hosted desktop editing with conditional source saves
This commit is contained in:
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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,520 @@
|
|||||||
|
//! 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.next = None;
|
||||||
|
self.hosted.selected = None;
|
||||||
|
}
|
||||||
|
Event::Token(token) => {
|
||||||
|
self.hosted.token = token;
|
||||||
|
self.hosted.connection = None;
|
||||||
|
}
|
||||||
|
Event::Query(query) => {
|
||||||
|
self.hosted.query = query;
|
||||||
|
self.hosted.next = None;
|
||||||
|
}
|
||||||
|
Event::Connect => match Connection::new(&self.hosted.base, self.hosted.token.clone()) {
|
||||||
|
Ok(connection) => {
|
||||||
|
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.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;
|
||||||
|
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;
|
||||||
|
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,445 @@
|
|||||||
|
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().to_owned()).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::Publish)).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;
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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,129 @@
|
|||||||
|
//! 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 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![],
|
||||||
|
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 {
|
||||||
|
/// 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(|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(|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(|value| Message::Hosted(Event::Query(value)))
|
||||||
|
.on_submit(Message::Hosted(Event::Connect)),
|
||||||
|
control("Search", Event::Connect, enabled),
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
);
|
||||||
|
if state.connection.is_some() && state.catalog.is_empty() {
|
||||||
|
content = content.push(label(
|
||||||
|
"No models found. Publish the current model or change the search.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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.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),
|
|
||||||
label(if app.dirty() {
|
|
||||||
"Unsaved changes"
|
|
||||||
} else if app.path.is_some() {
|
|
||||||
"All changes saved"
|
|
||||||
} else {
|
|
||||||
"Local project"
|
|
||||||
})
|
|
||||||
]
|
|
||||||
.spacing(3)
|
.spacing(3)
|
||||||
.width(Fill),
|
.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))
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Hosted desktop editing
|
||||||
|
|
||||||
|
Status: coherent implementation and 24 focused Windows tests pass; full desktop gates and native acceptance remain pending. 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
|
||||||
|
|
||||||
|
Run full desktop tests, strict all-target Clippy, formatting and optimized build. 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.
|
||||||
Reference in New Issue
Block a user