Skip to content

Commit dfc0ebf

Browse files
committed
feat(servers): write-only space config save with async file-watcher sync
Decouple Custom Server Configuration saves from the install pipeline. save_space_config now validates and writes the JSON file only; the existing space file watcher picks up the change and runs UserSpaceSyncService::sync_from_file asynchronously, emitting space-servers-updated/space-servers-sync-failed events instead of blocking the save on install-time errors. - space.rs: drop sync_from_file call from save_space_config - file_watcher.rs: derive space_id from filename, emit success/failure callbacks with unit tests - lib.rs: wire space-servers-updated / space-servers-sync-failed Tauri events from the watcher callbacks - useDomainEvents.ts: register both channels through the existing Tauri/web dispatcher instead of a page-level isTauri() branch - ServersPage.tsx: subscribe via useDomainEvents; warning toast on sync failure - ConfigEditorModal.tsx: drop refreshRegistry() from handleSave Also adds docs/planning/istauri-audit.md (isTauri() usage audit) and docs/planning/user-config-sync-collision-fix.md (follow-up plan for the cross-source ID collision bug this surfaced). Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 26318b1 commit dfc0ebf

11 files changed

Lines changed: 608 additions & 73 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,6 @@ apps/desktop/src-tauri/target/release/bundle/
9393
debug/
9494
*.dSYM/
9595

96+
97+
# Cursor rules — symlinked from ~/.cursor/rules/projects/
98+
.cursor/rules/

apps/desktop/src-tauri/src/commands/space.rs

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66
//! built-in fallback. The desktop UI tracks which space the user is
77
//! viewing in its own Zustand store (frontend-only state).
88
9-
use mcpmux_core::{
10-
application::UserSpaceSyncService, validate_workspace_root, Space, SpaceBaseDir,
11-
WorkspaceRootValidation,
12-
};
9+
use mcpmux_core::{validate_workspace_root, Space, SpaceBaseDir, WorkspaceRootValidation};
1310
use std::sync::Arc;
1411
use tauri::{AppHandle, State};
1512
use tokio::sync::RwLock;
@@ -238,24 +235,6 @@ pub async fn save_space_config(
238235
std::fs::write(&config_path, content)
239236
.map_err(|e| format!("Failed to write config file: {}", e))?;
240237

241-
// Do not rely solely on the debounced file watcher. The UI reloads immediately
242-
// after this command returns, so sync the just-saved file into InstalledServer
243-
// records synchronously to avoid stale/missing custom-server state.
244-
let sync_service = UserSpaceSyncService::new(state.installed_server_repository.clone());
245-
let sync_result = sync_service
246-
.sync_from_file(&space_id, &config_path)
247-
.await
248-
.map_err(|e| format!("Failed to sync custom server config: {}", e))?;
249-
250-
if sync_result.has_changes() {
251-
info!(
252-
"[save_space_config] Synced custom server config: {} added, {} updated, {} removed",
253-
sync_result.added.len(),
254-
sync_result.updated.len(),
255-
sync_result.removed.len()
256-
);
257-
}
258-
259238
Ok(())
260239
}
261240

apps/desktop/src-tauri/src/lib.rs

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -643,25 +643,49 @@ pub fn run() {
643643
let default_space_id = "00000000-0000-0000-0000-000000000001".to_string();
644644

645645
tauri::async_runtime::spawn(async move {
646+
let app_handle_success = app_handle_for_watcher.clone();
647+
let app_handle_error = app_handle_for_watcher;
646648

647649
// Create file watcher with UI event emitter
648650
match services::SpaceFileWatcher::new(
649651
spaces_dir.clone(),
650652
Arc::new(mcpmux_core::application::UserSpaceSyncService::new(installed_repo)),
651653
default_space_id,
652-
Some(move |space_id: &str, result: &mcpmux_core::application::SyncResult| {
653-
// Emit event to refresh UI
654-
if result.has_changes() {
655-
if let Err(e) = app_handle_for_watcher.emit("space-servers-updated", serde_json::json!({
656-
"space_id": space_id,
657-
"added": result.added,
658-
"updated": result.updated,
659-
"removed": result.removed,
660-
})) {
661-
warn!("[FileWatcher] Failed to emit event: {}", e);
662-
}
663-
}
664-
}),
654+
services::SpaceFileWatcherEmitters {
655+
on_success: Some(Arc::new(
656+
move |space_id: &str, result: &mcpmux_core::application::SyncResult| {
657+
if result.has_changes() {
658+
if let Err(e) = app_handle_success.emit(
659+
"space-servers-updated",
660+
serde_json::json!({
661+
"space_id": space_id,
662+
"added": result.added,
663+
"updated": result.updated,
664+
"removed": result.removed,
665+
}),
666+
) {
667+
warn!("[FileWatcher] Failed to emit event: {}", e);
668+
}
669+
}
670+
},
671+
)),
672+
on_error: Some(Arc::new(
673+
move |space_id: &str, message: &str| {
674+
if let Err(e) = app_handle_error.emit(
675+
"space-servers-sync-failed",
676+
serde_json::json!({
677+
"space_id": space_id,
678+
"message": message,
679+
}),
680+
) {
681+
warn!(
682+
"[FileWatcher] Failed to emit sync failure: {}",
683+
e
684+
);
685+
}
686+
},
687+
)),
688+
},
665689
) {
666690
Ok(_watcher) => {
667691
info!("[FileWatcher] Started watching: {:?}", spaces_dir);

apps/desktop/src-tauri/src/services/file_watcher.rs

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,20 @@ use anyhow::Result;
1212
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
1313
use tokio::sync::mpsc;
1414
use tracing::{debug, error, info, warn};
15+
use uuid::Uuid;
1516

1617
use mcpmux_core::application::{SyncResult, UserSpaceSyncService};
1718
use mcpmux_core::InstalledServerRepository;
1819

20+
type SyncSuccessHandler = Arc<dyn Fn(&str, &SyncResult) + Send + Sync>;
21+
type SyncErrorHandler = Arc<dyn Fn(&str, &str) + Send + Sync>;
22+
23+
/// Optional UI callbacks for background config sync.
24+
pub struct SpaceFileWatcherEmitters {
25+
pub on_success: Option<SyncSuccessHandler>,
26+
pub on_error: Option<SyncErrorHandler>,
27+
}
28+
1929
/// File watcher for user space configuration files
2030
///
2131
/// Monitors a directory for JSON file changes and syncs servers
@@ -34,17 +44,14 @@ impl SpaceFileWatcher {
3444
/// # Arguments
3545
/// * `spaces_dir` - Directory containing space JSON config files
3646
/// * `sync_service` - Service to sync changes
37-
/// * `default_space_id` - Default space ID to use for synced servers
38-
/// * `event_emitter` - Optional callback to emit UI events after sync
39-
pub fn new<F>(
47+
/// * `default_space_id` - Fallback space ID when filename stem is not a UUID
48+
/// * `emitters` - Optional callbacks for sync success/failure
49+
pub fn new(
4050
spaces_dir: PathBuf,
4151
sync_service: Arc<UserSpaceSyncService>,
4252
default_space_id: String,
43-
event_emitter: Option<F>,
44-
) -> Result<Self>
45-
where
46-
F: Fn(&str, &SyncResult) + Send + Sync + 'static,
47-
{
53+
emitters: SpaceFileWatcherEmitters,
54+
) -> Result<Self> {
4855
// Ensure directory exists
4956
if !spaces_dir.exists() {
5057
std::fs::create_dir_all(&spaces_dir)?;
@@ -56,10 +63,9 @@ impl SpaceFileWatcher {
5663
// Spawn debounced handler
5764
let sync_clone = sync_service.clone();
5865
let space_id = default_space_id.clone();
59-
let emitter = event_emitter.map(Arc::new);
6066

6167
tokio::spawn(async move {
62-
Self::debounced_handler(rx, sync_clone, space_id, emitter).await;
68+
Self::debounced_handler(rx, sync_clone, space_id, emitters).await;
6369
});
6470

6571
// Create file watcher
@@ -97,17 +103,24 @@ impl SpaceFileWatcher {
97103
})
98104
}
99105

106+
/// Resolve space ID from a config filename stem when it is a UUID.
107+
fn space_id_from_config_path(path: &Path, default_space_id: &str) -> String {
108+
path.file_stem()
109+
.and_then(|stem| stem.to_str())
110+
.filter(|stem| Uuid::parse_str(stem).is_ok())
111+
.map(String::from)
112+
.unwrap_or_else(|| default_space_id.to_string())
113+
}
114+
100115
/// Debounced handler for file changes
101116
///
102117
/// Groups rapid file changes and syncs after a debounce period.
103-
async fn debounced_handler<F>(
118+
async fn debounced_handler(
104119
mut rx: mpsc::Receiver<PathBuf>,
105120
sync_service: Arc<UserSpaceSyncService>,
106121
default_space_id: String,
107-
event_emitter: Option<Arc<F>>,
108-
) where
109-
F: Fn(&str, &SyncResult) + Send + Sync + 'static,
110-
{
122+
emitters: SpaceFileWatcherEmitters,
123+
) {
111124
let debounce_duration = Duration::from_millis(500);
112125
let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
113126

@@ -129,13 +142,12 @@ impl SpaceFileWatcher {
129142
for path in ready {
130143
pending.remove(&path);
131144

132-
// Extract space_id from filename (e.g., "default.json" -> use default_space_id)
133-
// For now, use the default space for all config files
134-
let space_id = &default_space_id;
145+
let space_id =
146+
Self::space_id_from_config_path(&path, &default_space_id);
135147

136148
info!("Syncing changes from: {:?}", path);
137149

138-
match sync_service.sync_from_file(space_id, &path).await {
150+
match sync_service.sync_from_file(&space_id, &path).await {
139151
Ok(result) => {
140152
if result.has_changes() {
141153
info!(
@@ -145,16 +157,19 @@ impl SpaceFileWatcher {
145157
result.removed.len()
146158
);
147159

148-
// Emit event for UI refresh
149-
if let Some(ref emitter) = event_emitter {
150-
emitter(space_id, &result);
160+
if let Some(ref emitter) = emitters.on_success {
161+
emitter(&space_id, &result);
151162
}
152163
} else {
153164
debug!("Sync complete: no changes");
154165
}
155166
}
156167
Err(e) => {
157-
error!("Sync failed for {:?}: {}", path, e);
168+
let message = e.to_string();
169+
error!("Sync failed for {:?}: {}", path, message);
170+
if let Some(ref emitter) = emitters.on_error {
171+
emitter(&space_id, &message);
172+
}
158173
}
159174
}
160175
}
@@ -198,11 +213,14 @@ impl SpaceFileWatcherBuilder {
198213
/// Build the file watcher without event emitter
199214
pub fn build(self) -> Result<SpaceFileWatcher> {
200215
let sync_service = Arc::new(UserSpaceSyncService::new(self.installed_repo));
201-
SpaceFileWatcher::new::<fn(&str, &SyncResult)>(
216+
SpaceFileWatcher::new(
202217
self.spaces_dir,
203218
sync_service,
204219
self.default_space_id,
205-
None,
220+
SpaceFileWatcherEmitters {
221+
on_success: None,
222+
on_error: None,
223+
},
206224
)
207225
}
208226

@@ -216,17 +234,34 @@ impl SpaceFileWatcherBuilder {
216234
self.spaces_dir,
217235
sync_service,
218236
self.default_space_id,
219-
Some(emitter),
237+
SpaceFileWatcherEmitters {
238+
on_success: Some(Arc::new(emitter)),
239+
on_error: None,
240+
},
220241
)
221242
}
222243
}
223244

224245
#[cfg(test)]
225246
mod tests {
247+
use super::*;
248+
use std::path::Path;
249+
250+
#[test]
251+
fn space_id_from_config_path_uses_uuid_stem() {
252+
let path = Path::new("/tmp/spaces/00000000-0000-0000-0000-000000000001.json");
253+
assert_eq!(
254+
SpaceFileWatcher::space_id_from_config_path(path, "fallback"),
255+
"00000000-0000-0000-0000-000000000001"
256+
);
257+
}
226258

227259
#[test]
228-
fn test_builder_default_space_id() {
229-
// Just test the builder pattern compiles
230-
// Actual functionality tested via integration tests
260+
fn space_id_from_config_path_falls_back_for_non_uuid_stem() {
261+
let path = Path::new("/tmp/spaces/default.json");
262+
assert_eq!(
263+
SpaceFileWatcher::space_id_from_config_path(path, "fallback-id"),
264+
"fallback-id"
265+
);
231266
}
232267
}

apps/desktop/src-tauri/src/services/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ pub mod file_watcher;
88
pub mod ui_events;
99

1010
pub use admin_server::AdminServerState;
11-
pub use file_watcher::SpaceFileWatcher;
11+
pub use file_watcher::{SpaceFileWatcher, SpaceFileWatcherEmitters};

apps/desktop/src/components/ConfigEditorModal.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback, useRef } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { X, Save, Loader2, AlertTriangle, Wand2, Plus } from 'lucide-react';
44
import { readSpaceConfig, saveSpaceConfig } from '@/lib/api/spaces';
5-
import { refreshRegistry } from '@/lib/api/registry';
65
import { type Monaco } from '@monaco-editor/react';
76
import type { editor } from 'monaco-editor';
87
import { useToast, ToastContainer } from '@mcpmux/ui';
@@ -143,8 +142,6 @@ export function ConfigEditorModal({
143142
setIsSaving(true);
144143
setError(null);
145144
await saveSpaceConfig(spaceId, content);
146-
// Refresh server discovery to pick up new/changed servers
147-
await refreshRegistry();
148145

149146
success(t('configEditorModal.toast.saved'), t('configEditorModal.toast.savedBody'));
150147
onSaved();

apps/desktop/src/features/servers/ServersPage.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ export function ServersPage() {
278278
const [actionLoading, setActionLoading] = useState<string | null>(null);
279279
const gatewayControl = useGatewayControl();
280280
// Bottom toast notifications
281-
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
281+
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' | 'warning' } | null>(null);
282282
const [configModal, setConfigModal] = useState<ConfigModalState>({
283283
open: false,
284284
server: null,
@@ -371,7 +371,7 @@ export function ServersPage() {
371371
}, [authProgress]);
372372

373373
// Show toast notification
374-
const showToast = useCallback((message: string, type: 'success' | 'error' | 'info' = 'info') => {
374+
const showToast = useCallback((message: string, type: 'success' | 'error' | 'info' | 'warning' = 'info') => {
375375
setToast({ message, type });
376376
setTimeout(() => setToast(null), 5000);
377377
}, []);
@@ -513,6 +513,27 @@ export function ServersPage() {
513513
});
514514
}, [loadData, subscribe, viewSpace]);
515515

516+
useEffect(() => {
517+
return subscribe('space-servers-updated', (payload) => {
518+
if (viewSpace && payload.space_id !== viewSpace.id) {
519+
return;
520+
}
521+
void loadData();
522+
});
523+
}, [loadData, subscribe, viewSpace]);
524+
525+
useEffect(() => {
526+
return subscribe('space-servers-sync-failed', (payload) => {
527+
if (viewSpace && payload.space_id !== viewSpace.id) {
528+
return;
529+
}
530+
showToast(
531+
`${t('configEditorModal.toast.syncFailedTitle')}: ${t('configEditorModal.toast.syncFailedBody', { message: payload.message })}`,
532+
'warning',
533+
);
534+
});
535+
}, [showToast, subscribe, t, viewSpace]);
536+
516537
useEffect(() => {
517538
return subscribe('server-update-available', (payload: ServerUpdateAvailablePayload) => {
518539
if (!viewSpace || payload.space_id !== viewSpace.id) {
@@ -2590,10 +2611,13 @@ export function ServersPage() {
25902611
? 'bg-[rgb(var(--success))]/90 border-[rgb(var(--success))] text-white'
25912612
: toast.type === 'error'
25922613
? 'bg-[rgb(var(--error))]/90 border-[rgb(var(--error))] text-white'
2614+
: toast.type === 'warning'
2615+
? 'bg-amber-500/90 border-amber-500 text-white'
25932616
: 'bg-[rgb(var(--primary))]/90 border-[rgb(var(--primary))] text-white'
25942617
}`}>
25952618
{toast.type === 'success' && <span className="text-lg"></span>}
25962619
{toast.type === 'error' && <span className="text-lg"></span>}
2620+
{toast.type === 'warning' && <span className="text-lg">!</span>}
25972621
{toast.type === 'info' && <span className="text-lg"></span>}
25982622
<span className="text-sm font-medium">{toast.message}</span>
25992623
<button

0 commit comments

Comments
 (0)