Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/desktop/loopx-control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ cargo run --example webcontent_recovery -- --baseline
```

It reads no Goals or accounts and never targets an installed LoopX process.

The fault injector uses a private WebKit process-id getter only in this example;
production recovery uses the public Tauri termination hook.
This controlled regression does not establish long-duration reliability or
Expand Down Expand Up @@ -188,6 +189,33 @@ rechecked at most every 30 seconds; an explicit recovery action wakes the
supervisor immediately. It returns to the loading shape as soon as the
snapshot leaves the terminal phase (for example while an explicit repair runs).

### Returning After A Normal Close

The packaged App remembers the selected local Goal, view, Agent and Todo when
its main window closes or the App quits. Reopening uses the newly allocated
loopback origin and returns to that selection without another navigation step.
Selecting **LoopX 管家** before closing instead returns to Manager next time.
This restores a reading position; it never submits a message or starts work.

The optional `workspace-location.json` in the App's private data directory
contains only those selection identifiers. It stores no origin, raw URL, prompt,
credentials or report/session query. Missing, corrupt or obsolete preferences
fall back to the ordinary workspace; unknown views fall back to the frontend's
default. Boot and foreign pages cannot overwrite a saved local selection.
Remote status sources and host-granted workspace contexts are not restored:
they reopen at local Manager, so their Goal identifiers cannot be replayed
against the local source. Vite development navigation is unchanged. Explicit
runtime repair retains its existing root handoff; WebContent crash recovery
continues to retain the current in-process route.

For native qualification, use a separately identified packaged App with the
real backend: select a local Goal and Agent page, quit normally, reopen, and
check the same selection on the new port. Also check window-close, an explicit
return to Manager and a corrupt preference in that disposable App profile.
Retain Session IDs and active-Turn readbacks before and after; navigation is
not evidence that work was resumed or accepted. Cross-platform and sustained
use require their own qualification.

## Known Issues

### Fresh Mac without a usable Python stays on the boot screen
Expand Down
18 changes: 14 additions & 4 deletions apps/desktop/loopx-control-plane/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod service_endpoints;
mod services;
mod update_backup;
mod workspace_handoff;
mod workspace_location;

use services::ServiceSet;
use std::sync::{
Expand All @@ -15,7 +16,7 @@ use std::sync::{
};
use tauri::{
ipc::CapabilityBuilder, webview::PageLoadEvent, AppHandle, Manager, RunEvent, Url, WebviewUrl,
WebviewWindowBuilder,
WebviewWindowBuilder, WindowEvent,
};
use tauri_plugin_notification::NotificationExt;
use workspace_handoff::{NavigationRetry, WorkspaceHandoff};
Expand Down Expand Up @@ -135,6 +136,11 @@ pub fn run() {
.build(),
)
.plugin(tauri_plugin_notification::init())
.on_window_event(|window, event| {
if window.label() == "main" && matches!(event, WindowEvent::CloseRequested { .. }) {
workspace_location::remember_current(window.app_handle());
}
})
.setup(move |app| {
let origin: Url = web_origin.parse()?;
app.add_capability(
Expand All @@ -155,7 +161,7 @@ pub fn run() {
};
let handoff = Arc::new(Mutex::new(WorkspaceHandoff::new(
boot_url(app.handle()),
origin.clone(),
workspace_location::launch_destination(app.handle(), &origin),
retry,
)));
app.manage(Arc::clone(&handoff));
Expand Down Expand Up @@ -220,7 +226,10 @@ pub fn run() {
}
let healed = started.healed;
*services_for_setup.lock().expect("service state lock") = Some(started);
handoff.lock().expect("workspace handoff lock").reconnect();
handoff
.lock()
.expect("workspace handoff lock")
.services_ready();
WorkspaceHandoff::reconcile(&handoff, &handle);
if healed {
let _ = handle
Expand Down Expand Up @@ -279,8 +288,9 @@ pub fn run() {
let app = builder
.build(tauri::generate_context!())
.expect("failed to build LoopX desktop shell");
app.run(move |_app, event| {
app.run(move |app, event| {
if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) {
workspace_location::remember_current(app);
shutting_down.store(true, Ordering::Release);
if let Ok(mut guard) = services.lock() {
if let Some(current) = guard.as_mut() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub(crate) enum NavigationRetry {
pub(crate) struct WorkspaceHandoff {
boot_url: Url,
workspace_url: Url,
launch_workspace_url: Option<Url>,
last_workspace_url: Option<Url>,
retry: NavigationRetry,
pending: bool,
Expand All @@ -25,9 +26,13 @@ pub(crate) struct WorkspaceHandoff {
impl WorkspaceHandoff {
const RETRY_INTERVAL: Duration = Duration::from_secs(2);
pub(crate) fn new(boot_url: Url, workspace_url: Url, retry: NavigationRetry) -> Self {
let mut root = workspace_url.clone();
root.set_query(None);
root.set_fragment(None);
Self {
boot_url,
workspace_url,
workspace_url: root,
launch_workspace_url: Some(workspace_url),
last_workspace_url: None,
retry,
pending: false,
Expand All @@ -36,10 +41,21 @@ impl WorkspaceHandoff {
}
pub(crate) fn reconnect(&mut self) {
// Explicit runtime repair retains its established workspace-root handoff.
self.launch_workspace_url = None;
self.last_workspace_url = None;
self.pending = true;
self.last_attempt = None;
}
pub(crate) fn services_ready(&mut self) {
// A normal launch consumes the saved selection once. Explicit runtime
// repair still returns to the root; it must not replay an old selection.
let launch = self.launch_workspace_url.take();
self.reconnect();
self.last_workspace_url = launch;
}
pub(crate) fn origin(&self) -> &Url {
&self.workspace_url
}
pub(crate) fn page_reached(&mut self, current: &Url) {
if current.origin() == self.workspace_url.origin() {
self.last_workspace_url = Some(current.clone());
Expand Down Expand Up @@ -128,6 +144,26 @@ pub(crate) fn content_lost(webview: &tauri::Webview) {
mod tests {
use super::*;
#[test]
fn normal_launch_resumes_once_but_explicit_repair_keeps_its_root_handoff() {
let launch: Url = "http://127.0.0.1:49123/chat/?goalId=example&view=tasks"
.parse()
.unwrap();
let root: Url = "http://127.0.0.1:49123/chat/".parse().unwrap();
let mut h = WorkspaceHandoff::new(
"tauri://localhost/".parse().unwrap(),
launch.clone(),
NavigationRetry::UntilNativeCommit,
);
assert!(!h.needs_navigation(Instant::now()));
h.services_ready();
assert_eq!(h.destination(), &launch);
h.page_reached(&launch);
h.reconnect();
assert_eq!(h.destination(), &root);
h.services_ready();
assert_eq!(h.destination(), &root);
}
#[test]
fn failed_workspace_load_retries_until_a_native_commit() {
let boot: Url = "tauri://localhost/".parse().unwrap();
let target: Url = "http://127.0.0.1:8767/chat/".parse().unwrap();
Expand Down
Loading
Loading