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
51 changes: 34 additions & 17 deletions apps/desktop/loopx-control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,23 +419,40 @@ restricted to loopback CORS and the existing preview/apply authority boundary.

## Coexistence With `loopx dashboard`

The packaged App allocates its own private Status and Chat endpoints for each
window. `loopx dashboard` keeps its CLI endpoints on `8766` and `8767`; either
entrypoint can run without adopting or stopping the other's listeners. Vite
development retains those CLI ports and its qualified-service reuse path.

The packaged App watches its own direct service children. When one exits, it
restarts that service on the same private endpoint with the same qualified
runtime and identity, keeping the healthy peer running. Recovery attempts are
at least two seconds apart. An occupied endpoint fails closed and is retried;
the intervening listener is never adopted or stopped. A live but unresponsive
child is outside this exit-recovery path.

Recovery keeps an update check's error or available-update choice visible. It
waits during maintenance and after an App replacement requires restart or leaves
the App incomplete. The native shell sends no conversation input or replay;
persisted Session and Turn recovery remain owned by Core. Closing the App stops
only its own service process groups.
The normal packaged App and `loopx dashboard` use the same machine Status and
Chat endpoints on `8766` and `8767`. The supervisor first qualifies the existing
LoopX listener and its exact runtime identity. It reuses a matching service and
keeps a loaded standard LaunchAgent as its owner; opening a window does not
create another Chat store or external-message consumer. A foreign or
unqualified listener is an error, never another port or an empty history.
A declared but unloaded machine LaunchAgent stays authoritative: the App reports
recovery instead of starting another service with an empty default store.
Core retains original binding, Session, Turn and delegation audience semantics;
sharing the service does not merge private conversations or widen permissions.

Closing a window never stops a borrowed service. Services the App itself starts
still retain the existing owned-process lifecycle; installing a persistent
machine service on a fresh machine is a separate lifecycle qualification.
A configured machine service must retain its original registry and Chat runtime,
rather than replacing them with an empty default store during adoption.

Developer/QA runs may explicitly set `LOOPX_DESKTOP_PRIVATE_ENDPOINTS=1` to use
the existing private-endpoint path (only `0` and `1` are accepted). Private ports
do not isolate stored data: QA must also pin a disposable registry/runtime through
the existing `LOOPX_BIN` test wrapper. In either mode the supervisor watches and
restarts only its own direct children on the same ports, with the same qualified
runtime and at least two seconds between recovery attempts. An occupied private endpoint
is never borrowed or stopped. This isolation is not the ordinary product path.

Both paths retain update errors, maintenance exclusion, existing preview/apply
authority and Core-owned Session recovery. The native shell sends no conversation
input and never replays a Turn to establish service readiness.

普通 App 与网页默认连接同一机器服务;打开窗口不再默认创建另一份 Chat 存储或
外部消息消费者。复用前核验服务及准确运行时身份;原 registry、Chat runtime、
Session 与委派受众保持不变,不混合不同私聊或扩大权限。借用的常驻服务不随窗口
关闭而停止。App 自己启动的服务保留既有生命周期,首次安装常驻服务仍单独验收。
开发隔离须明确设置上述环境变量;服务不可用或来源未核验时显示恢复,不切到空数据。

## Prerequisites

Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/loopx-control-plane/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ pub fn run() {
// Release builds load the versioned LoopX Chat workspace that ships inside
// the installed `loopx` release, so `loopx update` refreshes the frontend
// and backend together instead of reusing a separately built asset bundle.
let endpoints = service_endpoints::ServiceEndpoints::allocate(!cfg!(dev))
// App and browser are clients of the same machine services. Private
// endpoints are an explicit developer/QA choice, never a second default
// Chat store created merely by opening a release window.
let private_endpoints = std::env::var_os("LOOPX_DESKTOP_PRIVATE_ENDPOINTS");
let endpoints = service_endpoints::ServiceEndpoints::for_window(private_endpoints.as_deref())
.expect("could not allocate LoopX loopback endpoints");
#[cfg(dev)]
let web_origin = "http://127.0.0.1:5173".to_string();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use std::net::TcpListener;
use std::{ffi::OsStr, net::TcpListener};

use crate::services::ServiceKind;

/// A native window keeps one endpoint pair across runtime repairs. Release
/// windows own their services; Vite retains the CLI ports its proxy expects.
/// A native window keeps one endpoint pair across runtime repairs. Normal
/// windows share the machine services; explicit private endpoints are opt-in.
#[derive(Clone, Copy)]
pub(crate) struct ServiceEndpoints {
status: u16,
Expand All @@ -12,6 +12,21 @@ pub(crate) struct ServiceEndpoints {
}

impl ServiceEndpoints {
pub fn for_window(private_endpoints: Option<&OsStr>) -> std::io::Result<Self> {
let isolated = match private_endpoints {
None => false,
Some(value) if value == "0" => false,
Some(value) if value == "1" => true,
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"LOOPX_DESKTOP_PRIVATE_ENDPOINTS must be 0 or 1",
))
}
};
Self::allocate(isolated)
}

pub fn allocate(isolated: bool) -> std::io::Result<Self> {
if !isolated {
return Ok(Self {
Expand Down Expand Up @@ -62,6 +77,34 @@ fn reserve_private_endpoint() -> std::io::Result<TcpListener> {
mod tests {
use super::*;

#[test]
fn ordinary_window_uses_the_same_machine_service_addresses() {
for choice in [None, Some(OsStr::new("0"))] {
let endpoints = ServiceEndpoints::for_window(choice).unwrap();
assert_eq!(endpoints.port(ServiceKind::Status), 8766);
assert_eq!(endpoints.port(ServiceKind::Chat), 8767);
assert!(!endpoints.isolated);
}
assert!(
ServiceEndpoints::for_window(Some(OsStr::new("1")))
.unwrap()
.isolated
);
}

#[test]
fn invalid_private_endpoint_choice_cannot_fall_through_to_machine_state() {
for choice in ["", "true", "yes", "2"] {
assert_eq!(
ServiceEndpoints::for_window(Some(OsStr::new(choice)))
.err()
.unwrap()
.kind(),
std::io::ErrorKind::InvalidInput
);
}
}

#[test]
fn vite_keeps_its_proxy_endpoints() {
let endpoints = ServiceEndpoints::allocate(false).unwrap();
Expand Down
35 changes: 29 additions & 6 deletions apps/desktop/loopx-control-plane/src-tauri/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,17 +207,16 @@ impl ServiceSet {
}
}

/// Recover only this window's exited private children. Keep its qualified
/// Recover only this window's exited owned children. Keep its qualified
/// artifact, ports and healthy peer; never infer ownership from a listener.
pub(crate) fn recover_exited(
&mut self,
endpoints: &ServiceEndpoints,
now: Instant,
) -> Result<bool, ServiceError> {
if !endpoints.isolated
|| self
.last_recovery_attempt
.is_some_and(|at| now.duration_since(at) < Duration::from_secs(2))
if self
.last_recovery_attempt
.is_some_and(|at| now.duration_since(at) < Duration::from_secs(2))
{
return Ok(false);
}
Expand Down Expand Up @@ -353,7 +352,7 @@ fn connect(
}
}

// Release windows never borrow a listener or restart a LaunchAgent. A matching
// Explicit private endpoints never borrow a listener or restart a LaunchAgent. A matching
// revision alone cannot establish the listener's registry/process ownership.
fn connect_owned_service(
kind: ServiceKind,
Expand Down Expand Up @@ -508,6 +507,16 @@ fn connect_service(
)));
}

// A declared machine service owns its registry and Chat store even while
// launchd has it unloaded. Do not replace that source with an empty default.
#[cfg(target_os = "macos")]
if platform_managed_service_declared(env::var_os("HOME").as_deref(), kind) {
return Err(ServiceError(format!(
"system-managed LoopX {} is configured but not loaded; restore its service and retry",
kind.label()
)));
}

let mut command = Command::new(executable);
configure_runtime_environment(&mut command);
if matches!(kind, ServiceKind::Chat) {
Expand Down Expand Up @@ -590,6 +599,20 @@ fn request_platform_managed_start(kind: ServiceKind) -> bool {
true
}

#[cfg(target_os = "macos")]
fn platform_managed_service_declared(home: Option<&OsStr>, kind: ServiceKind) -> bool {
let Some(home) = home else {
return false;
};
let path = PathBuf::from(home)
.join("Library/LaunchAgents")
.join(format!("{}.plist", platform_managed_service_label(kind)));
match std::fs::symlink_metadata(path) {
Ok(_) => true,
Err(error) => error.kind() != std::io::ErrorKind::NotFound,
}
}

fn platform_managed_service_label(kind: ServiceKind) -> &'static str {
match kind {
ServiceKind::Status => "com.loopx.status",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,7 @@ fn exit_private_service(root: &Path, kind: ServiceKind, services: &mut ServiceSe

#[cfg(unix)]
#[test]
fn exited_private_children_recover_individually_with_the_frozen_identity() {
fn exited_owned_children_recover_individually_with_the_frozen_identity() {
let _fixture = PRIVATE_RECOVERY_FIXTURE.lock().unwrap();
let (root, runtime, endpoints) = private_recovery_fixture();
let mut services = ServiceSet::start(&runtime, &endpoints, |_| {}).unwrap();
Expand All @@ -689,10 +689,12 @@ fn exited_private_children_recover_individually_with_the_frozen_identity() {
services.last_recovery_attempt = None;
let mut development = endpoints;
development.isolated = false;
assert!(!services
// Ownership, rather than private/shared mode, authorizes recovery.
// Keep ephemeral fixture ports so this cannot touch machine services.
assert!(services
.recover_exited(&development, Instant::now())
.unwrap());
assert!(services.recover_exited(&endpoints, Instant::now()).unwrap());
assert!(!services.recover_exited(&endpoints, Instant::now()).unwrap());
assert_ne!(
services
.owned
Expand Down Expand Up @@ -837,3 +839,29 @@ fn private_recovery_cannot_start_an_unowned_or_development_service() {
.recover_exited(&ServiceEndpoints::allocate(false).unwrap(), Instant::now())
.unwrap());
}

#[cfg(target_os = "macos")]
#[test]
fn declared_machine_source_remains_authoritative_while_unloaded() {
let root = tempfile::tempdir().unwrap();
assert!(!platform_managed_service_declared(
Some(root.path().as_os_str()),
ServiceKind::Chat
));
let launch_agents = root.path().join("Library/LaunchAgents");
fs::create_dir_all(&launch_agents).unwrap();
// Even a damaged declaration must not admit a different empty Chat store.
fs::write(
launch_agents.join("com.loopx.chat.plist"),
b"damaged declaration",
)
.unwrap();
assert!(platform_managed_service_declared(
Some(root.path().as_os_str()),
ServiceKind::Chat
));
assert!(!platform_managed_service_declared(
Some(root.path().as_os_str()),
ServiceKind::Status
));
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,58 @@ import {Button} from "../../components/ui/button";
import {fetchAppStewardTarget, type BoundConversationTarget} from "../../data/chat";
import {useWorkspaceI18n} from "./i18n";

/** Only mount at the local, unbound manager Chat entry. Children must not mount
* until discovery finishes: they otherwise expose the ordinary manager queue.
* Once opened, the route freezes the exact Session and this boundary unmounts.
/** One read-only resolver for both App entrypoints. Pending/failed discovery
* never exposes the ordinary manager queue. Explicit bound routes bypass it.
*/
export function AppStewardEntry({children, onOpen}: {children: ReactNode; onOpen: (target: BoundConversationTarget) => void}) {
const {locale} = useWorkspaceI18n();
const zh = locale === "zh-CN";
const [state, setState] = useState<"loading" | "ordinary" | "unavailable">("loading");
const [revision, setRevision] = useState(0);
const open = useRef(onOpen);
open.current = onOpen;
export function useAppStewardTarget(enabled: boolean) {
const [read, setRead] = useState<{enabled: boolean; revision: number; ready: boolean;
target: BoundConversationTarget | null; unavailable: boolean}>(
{enabled, revision: 0, ready: false, target: null, unavailable: false});
// Invalidate in the render that changes scope, before a composer can submit
// using a previous ordinary result. React retries this component immediately.
if (read.enabled !== enabled) setRead({...read, enabled, ready: false, target: null, unavailable: false});
const retry = () => setRead(value => ({...value, revision: value.revision + 1, ready: false, target: null, unavailable: false}));
useEffect(() => {
const changed = () => {setState("loading"); setRevision(value => value + 1);};
window.addEventListener("loopx:app-steward-changed", changed);
return () => window.removeEventListener("loopx:app-steward-changed", changed);
window.addEventListener("loopx:app-steward-changed", retry);
return () => window.removeEventListener("loopx:app-steward-changed", retry);
}, []);
const revision = read.revision;
useEffect(() => {
if (!enabled) return;
let active = true;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
void fetchAppStewardTarget(controller.signal).then(target => {
if (!active) return;
if (target) open.current(target);
else setState("ordinary");
}).catch(() => {if (active) setState("unavailable");}).finally(() => clearTimeout(timeout));
const settle = (target: BoundConversationTarget | null, unavailable: boolean) => {
if (active) setRead(value => value.enabled === enabled && value.revision === revision
? {...value, ready: true, target, unavailable} : value);
};
void fetchAppStewardTarget(controller.signal).then(target => settle(target, false))
.catch(() => settle(null, true)).finally(() => clearTimeout(timeout));
return () => {active = false; controller.abort(); clearTimeout(timeout);};
}, [revision]);
if (state === "ordinary") return children;
}, [enabled, revision]);
const ready = enabled && read.enabled === enabled && read.ready;
return {target: ready ? read.target : null, loading: enabled && !ready,
unavailable: ready && read.unavailable, retry};
}

export function AppStewardEntry({children, onOpen}: {children: ReactNode; onOpen: (target: BoundConversationTarget) => void}) {
const {locale} = useWorkspaceI18n();
const zh = locale === "zh-CN";
const entry = useAppStewardTarget(true);
const open = useRef(onOpen);
open.current = onOpen;
useEffect(() => {if (entry.target) open.current(entry.target);}, [entry.target]);
if (!entry.loading && !entry.unavailable && !entry.target) return children;
const settings = new URL(window.location.href);
settings.searchParams.set("view", "overview");
settings.searchParams.set("settings_scope", "machine");
settings.searchParams.set("settings_tab", "lark");
return <main className="mx-auto flex min-h-dvh max-w-xl flex-col justify-center gap-5 p-6 text-slate-900 dark:text-zinc-100">
<h1 className="text-xl font-semibold">{zh ? "LoopX 管家" : "LoopX steward"}</h1>
{state === "loading" ? <p role="status">{zh ? "正在打开管家对话…" : "Opening the steward conversation…"}</p> : <>
{!entry.unavailable ? <p role="status">{zh ? "正在打开管家对话…" : "Opening the steward conversation…"}</p> : <>
<p role="alert">{zh ? "原管家会话暂不可用。请检查所选连接后重试。" : "The original steward conversation is unavailable. Check the selected connection and retry."}</p>
<div className="flex flex-wrap gap-3">
<Button onClick={() => {setState("loading"); setRevision(value => value + 1);}}>{zh ? "重试" : "Retry"}</Button>
<Button onClick={entry.retry}>{zh ? "重试" : "Retry"}</Button>
<a className="inline-flex min-h-9 items-center underline underline-offset-4" href={settings.href}>{zh ? "打开会话设置" : "Open conversation settings"}</a>
</div>
</>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export type WorkspaceConversationDirectory = {
/** null returns the conversation to the steward scope. */
onSelect: (projectRef: string | null) => void;
onOpenOriginal?: (target: BoundConversationTarget) => void;
/** Local manager entry discovery blocks sending, never Goal navigation. */
entryGate?: {unavailable: boolean; retry: () => void};
bound?: { target: BoundConversationTarget; session?: ChatSessionSummary; source?: PrivateConversation;
error?: string; retry: () => void };
managed?: {target: import("../../data/chat").ManagedConversationTarget; session?: import("../../data/chat").ChatSessionSummary; error?: string; retry: () => void};
Expand Down
Loading
Loading