From 1b80671dd074c5818a03ead296d09be9b5ea16cc Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 07:16:55 +0300 Subject: [PATCH] Add optional hosted desktop editing with conditional source saves --- Cargo.lock | 6 + crates/oa-desktop/Cargo.toml | 9 + crates/oa-desktop/src/app.rs | 33 +- crates/oa-desktop/src/app/hosted.rs | 520 ++++++++++++++++ crates/oa-desktop/src/app/hosted/tests.rs | 445 +++++++++++++ crates/oa-desktop/src/hosted/client.rs | 621 +++++++++++++++++++ crates/oa-desktop/src/hosted/client_tests.rs | 609 ++++++++++++++++++ crates/oa-desktop/src/hosted/mod.rs | 129 ++++ crates/oa-desktop/src/hosted/view.rs | 238 +++++++ crates/oa-desktop/src/main.rs | 1 + crates/oa-desktop/src/views.rs | 18 +- harness/release-hosted-desktop.md | 34 + 12 files changed, 2647 insertions(+), 16 deletions(-) create mode 100644 crates/oa-desktop/src/app/hosted.rs create mode 100644 crates/oa-desktop/src/app/hosted/tests.rs create mode 100644 crates/oa-desktop/src/hosted/client.rs create mode 100644 crates/oa-desktop/src/hosted/client_tests.rs create mode 100644 crates/oa-desktop/src/hosted/mod.rs create mode 100644 crates/oa-desktop/src/hosted/view.rs create mode 100644 harness/release-hosted-desktop.md diff --git a/Cargo.lock b/Cargo.lock index fe76f504..30921add 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2155,10 +2155,16 @@ dependencies = [ name = "oa-desktop" version = "0.2.0" dependencies = [ + "axum", "iced", + "iced_runtime", "oa-core", + "oa-server", + "reqwest", "rfd", + "serde", "serde_json", + "tempfile", "tokio", ] diff --git a/crates/oa-desktop/Cargo.toml b/crates/oa-desktop/Cargo.toml index 7c508ff1..d7fa0fdb 100644 --- a/crates/oa-desktop/Cargo.toml +++ b/crates/oa-desktop/Cargo.toml @@ -13,5 +13,14 @@ path = "src/main.rs" oa-core = { path = "../oa-core" } iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] } rfd = "0.15" +serde.workspace = true serde_json.workspace = true +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1", features = ["rt", "time"] } + +[dev-dependencies] +iced_runtime = "0.14" +oa-server = { path = "../oa-server" } +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] } +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] } diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 62c32600..25ce58d3 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -1,3 +1,4 @@ +mod hosted; use crate::forms::Draft; use crate::{ document::Document, @@ -280,6 +281,7 @@ impl std::fmt::Display for Choice { } #[derive(Debug, Clone, Copy)] pub enum Pending { + HostedOpen, New, Demo, Open, @@ -299,6 +301,7 @@ pub struct FilePrompt { #[derive(Debug, Clone)] pub enum Message { + Hosted(crate::hosted::Event), PortAnchor(crate::port_edit::Event), Connection(crate::connection_edit::Event), Menu(crate::model_menu::Event), @@ -410,6 +413,7 @@ pub enum Message { } pub struct App { + pub hosted: crate::hosted::State, pub connection: Option, pub model_menu: Option, pub source_tree: crate::source_tree::State, @@ -525,6 +529,7 @@ impl App { Kind::Activity }, status: "Coastal rescue example · explore both engineering perspectives".into(), + hosted: Default::default(), busy: false, pending: None, delete_pending: false, @@ -844,7 +849,10 @@ impl App { let source_version = self.source_version + 1; let lint_running = self.lint_running; let guide = std::mem::take(&mut self.guide); + let mut hosted = std::mem::take(&mut self.hosted); + hosted.leave_document(); *self = Self::with_project(project); + self.hosted = hosted; self.window_size = window_size; self.canvas_size = canvas_size; self.source_version = source_version; @@ -906,8 +914,12 @@ impl App { } } pub fn view(&self) -> Element<'_, Message> { - crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self)) - .into() + let base = crate::context_menu::Popup::new( + crate::views::view(self), + crate::model_menu::popup(self), + ) + .into(); + crate::hosted::view::overlay(self, base) } pub fn subscription(&self) -> Subscription { Subscription::batch([ @@ -1019,6 +1031,7 @@ impl App { && !matches!( message, Message::Saved(_) + | Message::Hosted(crate::hosted::Event::Completed(..)) | Message::Loaded(_) | Message::IncludesLoaded(_, _) | Message::SavePath(_) @@ -1038,6 +1051,10 @@ impl App { message, Message::Escape | Message::Request(Pending::Close(_)) ) { + if self.hosted.visible { + self.hosted.visible = false; + return Task::none(); + } if self.workflow.take().is_some() { return Task::none(); } @@ -1062,6 +1079,7 @@ impl App { | Message::Lint(_) | Message::Linted(_, _) | Message::Saved(_) + | Message::Hosted(crate::hosted::Event::Completed(..)) | Message::Loaded(_) | Message::IncludesLoaded(_, _) | Message::SavePath(_) @@ -1069,7 +1087,8 @@ impl App { | Message::Exported(_) ); if !modal_event - && ((self.workflow.is_some() && !matches!(message, Message::Workflow(_))) + && ((self.hosted.visible && !matches!(message, Message::Hosted(_))) + || (self.workflow.is_some() && !matches!(message, Message::Workflow(_))) || (self.file_prompt.is_some() && !matches!( message, @@ -1187,6 +1206,7 @@ impl App { } fn handle(&mut self, message: Message) -> Task { match message { + Message::Hosted(event) => return self.hosted_event(event), Message::ScenarioCanvas(event) => { use crate::behavior::scenario::{self, CanvasEvent}; let context = event.context(); @@ -2490,6 +2510,9 @@ impl App { return self.handle(Message::Save(false)); } Message::Save(save_as) => { + if !save_as && self.hosted.session.is_some() { + return self.save_hosted(); + } if !self.apply_behavior() || !self.apply_source() || !self.apply() { self.continue_after_save = false; self.pending = None; @@ -2541,6 +2564,9 @@ impl App { } self.disk_project = Some(disk); self.path = Some(receipt.path); + self.hosted.session = None; + self.hosted.review = None; + self.hosted.uncertain = None; self.document.mark_saved(receipt.snapshot); self.prune_closed_files(!self.document.dirty()); self.status = "All source files saved".into(); @@ -2879,6 +2905,7 @@ impl App { fn proceed(&mut self, pending: Pending) -> Task { self.pending = None; match pending { + Pending::HostedOpen => self.open_hosted(), Pending::New => { self.reset(Project::empty(), None); Task::none() diff --git a/crates/oa-desktop/src/app/hosted.rs b/crates/oa-desktop/src/app/hosted.rs new file mode 100644 index 00000000..a51081c8 --- /dev/null +++ b/crates/oa-desktop/src/app/hosted.rs @@ -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) { + 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, + ) -> Task { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/crates/oa-desktop/src/app/hosted/tests.rs b/crates/oa-desktop/src/app/hosted/tests.rs new file mode 100644 index 00000000..69163bc3 --- /dev/null +++ b/crates/oa-desktop/src/app/hosted/tests.rs @@ -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, resource_id: Id, revision: u64) -> Remote { + Remote { + resource_id, + revision, + etag: format!("\"opaque-{revision}\""), + compiled: snapshot, + } +} +fn complete(app: &mut App, result: Result) { + let r = app.hosted.request.as_ref().unwrap(); + send(app, Event::Completed(r.id, r.document, result)); +} +fn failure(status: Option, 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(); +} diff --git a/crates/oa-desktop/src/hosted/client.rs b/crates/oa-desktop/src/hosted/client.rs new file mode 100644 index 00000000..55911080 --- /dev/null +++ b/crates/oa-desktop/src/hosted/client.rs @@ -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, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Catalog { + pub items: Vec, + pub next_after: Option, +} + +#[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, + /// 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, +} + +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, +} + +#[derive(Deserialize)] +struct ProblemIssue { + message: String, + path: Option, + line: Option, + column: Option, +} + +struct Reply { + status: u16, + etag: Option, + body: Vec, +} + +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::with_timeout(base, token, REQUEST_TIMEOUT) + } + + fn with_timeout(base: &str, token: Secret, timeout: Duration) -> Result { + 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::() + .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(&self, reply: &Reply, mutation: bool) -> Result { + 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 { + 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, + etag: Option<&str>, + ) -> Result { + 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::(&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) -> Result { + 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 { + 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 { + 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 { + 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, Failure> { + let result = tokio::task::spawn_blocking(move || { + let compiled = source_project::compile_bundle(&bundle, SOURCE_LIMITS)?; + Ok::<_, Vec>((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; diff --git a/crates/oa-desktop/src/hosted/client_tests.rs b/crates/oa-desktop/src/hosted/client_tests.rs new file mode 100644 index 00000000..e5f43f95 --- /dev/null +++ b/crates/oa-desktop/src/hosted/client_tests.rs @@ -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>, + task: JoinHandle<()>, + _directory: Option, +} + +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, 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| { + 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) -> (String, Arc, 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::().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()); +} diff --git a/crates/oa-desktop/src/hosted/mod.rs b/crates/oa-desktop/src/hosted/mod.rs new file mode 100644 index 00000000..f957c38b --- /dev/null +++ b/crates/oa-desktop/src/hosted/mod.rs @@ -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), +} + +#[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, + pub etag: Option, + pub snapshot: Arc, +} + +#[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, + pub next: Option, + pub selected: Option, + pub connection: Option, + pub session: Option, + pub request: Option, + pub uncertain: Option, + pub review: Option, + pub review_path: Option, + pub review_editor: iced::widget::text_editor::Content, + pub error: Option, + 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; + } +} diff --git a/crates/oa-desktop/src/hosted/view.rs b/crates/oa-desktop/src/hosted/view.rs new file mode 100644 index 00000000..ff5c68f4 --- /dev/null +++ b/crates/oa-desktop/src/hosted/view.rs @@ -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)) +} diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index 8926c627..501b7ed4 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -13,6 +13,7 @@ mod explorer_view; mod forms; mod guide; mod highlight; +mod hosted; mod model_menu; mod navigation; mod port_edit; diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 7c427e43..64d93d67 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -55,22 +55,14 @@ pub fn view(app: &App) -> View<'_, Message> { ] .spacing(10) .width(220), - column![ - text(short(&p.name, 46)).size(17), - label(if app.dirty() { - "Unsaved changes" - } else if app.path.is_some() { - "All changes saved" - } else { - "Local project" - }) - ] - .spacing(3) - .width(Fill), + column![text(short(&p.name, 46)).size(17), label(app.hosted_label())] + .spacing(3) + .width(Fill), action("New", Message::Request(Pending::New)), + crate::hosted::view::header_action(), action("Open", Message::Request(Pending::Open)), action("Save as", Message::Save(true)), - button(text("Save all").size(12)) + button(text(app.save_label()).size(12)) .padding([10, 16]) .on_press(Message::Save(false)) ] diff --git a/harness/release-hosted-desktop.md b/harness/release-hosted-desktop.md new file mode 100644 index 00000000..480e1b80 --- /dev/null +++ b/harness/release-hosted-desktop.md @@ -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.