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
17 changes: 15 additions & 2 deletions docs/architecture/peer-device-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Two concepts, deliberately independent:
|---|---|---|
| What it is | A live control link to a peer | The one device this window draws |
| How many | Any number, concurrently | Exactly one |
| Ends when | Explicit disconnect, peer offline, logout | Replaced by the next switch |
| Ends when | Explicit disconnect or logout | Replaced by the next switch |
| Effect on the peer's agent | Keeps it running and fanning out | None |

This split is what makes several devices usable at once: dispatch a turn on B,
Expand Down Expand Up @@ -69,10 +69,23 @@ requests coalesce to the last target, a committed-but-superseded hydrate is
invalidated before the next target proceeds, and a real activation failure
rolls back to the previously rendered reachable surface. Separately,
`PeerConnectionManager` owns each attachment's
`connecting`/`ready`/`degraded`/`lost` lifecycle, keepalive and bounded backoff;
`connecting`/`ready`/`degraded` lifecycle, keepalive and capped backoff;
React only subscribes to snapshots. Attachment disposal is the only operation
that discards a peer's cached surface state.

Presence gaps and product RPC transport failures move an established attachment
into `degraded`; they never select the local surface. Only a dedicated
`peer_mode_ping` plus recovery `peer_control_attach` handshake changes it back
to `ready`. Product timeouts do not count as independent failed health checks.
Recovery uses one in-flight handshake per device, retries with exponential
backoff capped at 15 seconds, and continues until explicit disconnect/logout.
A device returning to account presence accelerates a pending retry without
claiming the control link is already restored. Cached capabilities, the surface
epoch, requests' target device and session projections stay with that peer;
recovery neither reboots the surface nor resubmits a Turn. The window displays
a persistent reconnecting notice with a manual return-to-local action while its
selected peer is degraded. Background peers recover without switching the view.

Because the local surface can now miss its own events while another device is
rendered, Session attachment is no longer Peer-only. After this window's first
surface switch, `isSurfaceReconcileEnabled()` attaches whichever surface is
Expand Down
1 change: 1 addition & 0 deletions src/apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ Run the smallest checks matching the changed path:
```bash
cargo check -p openbitfun-cli
cargo test -p openbitfun-cli
cargo test -p openbitfun-cli --bin openbitfun system_info_home_contract
```

For streaming `exec` retry, context recovery, and final-event contracts:
Expand Down
13 changes: 13 additions & 0 deletions src/apps/cli/src/peer_host/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub(crate) async fn get_system_info() -> Result<Value, String> {
"platform": info.platform,
"arch": info.arch,
"osVersion": info.os_version,
"homeDir": info.home_dir,
}))
}

Expand All @@ -32,3 +33,15 @@ pub(crate) async fn get_token_usage_statistics(
.map_err(|error| error.to_string())?;
serde_json::to_value(statistics).map_err(|error| error.to_string())
}

#[cfg(test)]
mod tests {
#[tokio::test]
async fn system_info_home_contract_reports_serving_host_in_camel_case() {
let response = super::get_system_info().await.unwrap();
let info = openbitfun_core::service::system::get_system_info();
assert_eq!(response["homeDir"], serde_json::json!(info.home_dir));
assert!(response.get("home_dir").is_none());
assert_eq!(response["platform"], info.platform);
}
}
2 changes: 2 additions & 0 deletions src/apps/desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ cargo check -p openbitfun-desktop && cargo test -p openbitfun-desktop

For staged application-update cache and signature behavior, use
`cargo test -p openbitfun-desktop --lib api::update_api::tests`.
For peer system-info response compatibility, run
`cargo test -p openbitfun-desktop --lib system_info_home_contract`.
After changing updater command registration, also run
`cargo test -p openbitfun-desktop --lib remote_workspace_policy`.

Expand Down
22 changes: 22 additions & 0 deletions src/apps/desktop/src/api/system_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ pub struct SystemInfoResponse {
pub platform: String,
pub arch: String,
pub os_version: Option<String>,
#[serde(default)]
pub home_dir: Option<String>,
}

#[tauri::command]
Expand All @@ -248,6 +250,7 @@ pub async fn get_system_info() -> Result<SystemInfoResponse, String> {
platform: info.platform,
arch: info.arch,
os_version: info.os_version,
home_dir: info.home_dir,
})
}

Expand Down Expand Up @@ -1031,6 +1034,25 @@ fn activate_main_window_from_notification(app: &tauri::AppHandle) {

#[cfg(test)]
mod tests {
#[tokio::test]
async fn system_info_home_contract_accepts_legacy_and_reports_serving_host() {
let legacy =
serde_json::json!({"platform": "windows", "arch": "x86_64", "osVersion": null});
let old: super::SystemInfoResponse = serde_json::from_value(legacy).unwrap();
assert!(old.home_dir.is_none());
let round_trip: super::SystemInfoResponse =
serde_json::from_value(serde_json::to_value(old).unwrap()).unwrap();
assert_eq!(round_trip.platform, "windows");
assert!(round_trip.home_dir.is_none());

let response = serde_json::to_value(super::get_system_info().await.unwrap()).unwrap();
assert_eq!(
response["homeDir"],
serde_json::json!(super::system::get_system_info().home_dir)
);
assert!(response.get("home_dir").is_none());
}

#[test]
fn startup_window_control_contract_exposes_the_native_maximize_state() {
let request: super::StartupWindowControlRequest =
Expand Down
1 change: 1 addition & 0 deletions src/crates/services/services-core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ target. Representative stable entry points are:

```bash
cargo check -p openbitfun-services-core --no-default-features
cargo test -p openbitfun-services-core --no-default-features --features process-runtime --lib system::info::tests
cargo test -p openbitfun-services-core --no-default-features --features credential-vault --lib credential_vault::tests::
cargo check -p openbitfun-services-core --no-default-features --features filesystem
cargo test -p openbitfun-services-core --no-default-features --features diagnostics --lib diagnostics::contract_tests::
Expand Down
29 changes: 29 additions & 0 deletions src/crates/services/services-core/src/system/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ pub struct SystemInfo {
pub arch: String,
/// OS version
pub os_version: Option<String>,
/// User home on the host serving this request, never on its controller.
#[serde(default)]
pub home_dir: Option<String>,
}

/// Gets system info.
Expand Down Expand Up @@ -42,5 +45,31 @@ pub fn get_system_info() -> SystemInfo {
platform: platform.to_string(),
arch: arch.to_string(),
os_version: None,
home_dir: std::env::home_dir().and_then(|path| path.into_os_string().into_string().ok()),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn older_system_info_without_home_remains_readable() {
let legacy =
serde_json::json!({"platform": "windows", "arch": "x86_64", "os_version": null});
let info: SystemInfo = serde_json::from_value(legacy.clone()).unwrap();
assert!(info.home_dir.is_none());
let round_trip: SystemInfo =
serde_json::from_value(serde_json::to_value(info).unwrap()).unwrap();
assert_eq!(round_trip.platform, legacy["platform"]);
assert!(round_trip.home_dir.is_none());
}

#[test]
fn reports_the_serving_hosts_home_directory() {
assert_eq!(
get_system_info().home_dir,
std::env::home_dir().and_then(|path| path.into_os_string().into_string().ok())
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { useI18n } from '@/infrastructure/i18n/hooks/useI18n';
import { useSceneStore } from '../../../stores/sceneStore';
import { activateProductAction } from '@/app/global-search/productActionActivator';
import { useToolbarModeContext } from '@/flow_chat/components/toolbar-mode/ToolbarModeContext';
import { useNotification } from '@/shared/notification-system';
import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI';
import NotificationButton from '../../TitleBar/NotificationButton';
import { RemoteConnectDisclaimerContent } from '../../RemoteConnectDialog/RemoteConnectDisclaimer';
Expand All @@ -43,22 +42,6 @@ const PersistentFooterActions: React.FC = () => {
const { t } = useI18n('common');
const activeTabId = useSceneStore((s) => s.activeTabId);
const { enableToolbarMode } = useToolbarModeContext();
const { warning } = useNotification();

useEffect(() => {
const onAutoExit = (event: Event) => {
const detail = (event as CustomEvent<{ deviceName?: string; reason?: string }>).detail;
const name = detail?.deviceName || 'peer';
if (detail?.reason === 'peer_offline') {
warning(t('accountLogin.peerAutoExitOffline', { name }));
} else if (detail?.reason === 'rpc_failures') {
warning(t('accountLogin.peerAutoExitRpc', { name }));
}
};
window.addEventListener('peer-mode:auto-exit', onAutoExit);
return () => window.removeEventListener('peer-mode:auto-exit', onAutoExit);
}, [t, warning]);

const [menuOpen, setMenuOpen] = useState(false);
const [menuClosing, setMenuClosing] = useState(false);
const [appearanceSubmenuOpen, setAppearanceSubmenuOpen] = useState(false);
Expand Down
3 changes: 3 additions & 0 deletions src/web-ui/src/app/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { openMainSession } from '@/flow_chat/services/sessionActivation';
import { notificationService } from '@/shared/notification-system';
import { api } from '@/infrastructure/api/service-api/ApiClient';
import { AppearanceBackgroundMediaLayer, appearanceRuntime, useAppearance } from '@/infrastructure/appearance';
import { PeerConnectionStatus } from '@/infrastructure/peer-device/PeerConnectionStatus';
import './AppLayout.scss';

type TransitionDirection = 'entering' | 'returning' | null;
Expand Down Expand Up @@ -710,6 +711,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
<Suspense fallback={null}>
<ToolbarMode />
</Suspense>
<PeerConnectionStatus />
</div>
</>
);
Expand Down Expand Up @@ -756,6 +758,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
isExiting={transitionDir === 'returning'}
/>
</main>
<PeerConnectionStatus />

{/* Hello stays available across every client scene, including Welcome. */}
<Suspense fallback={null}>
Expand Down
10 changes: 9 additions & 1 deletion src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,17 @@ export interface ToggleMainWindowFullscreenResponse {
/** Close-button behavior values (matches `app.close_button_behavior` config key). */
export type CloseBehavior = 'quit' | 'minimize_to_tray' | 'ask';

export interface SystemInfo {
platform: string;
arch: string;
osVersion?: string | null;
/** Absent on older peers. Always belongs to the host serving the request. */
homeDir?: string | null;
}

export class SystemAPI {

async getSystemInfo(): Promise<any> {
async getSystemInfo(): Promise<SystemInfo> {
try {
return await api.invoke('get_system_info', {
request: {}
Expand Down
Loading
Loading