Skip to content

Commit 9af05c0

Browse files
committed
feat(routing): Phase 2 — id-type bindings + resolver Tier 2
Autonomous decisions: - Store id binding key in workspace_root with binding_type=id — keeps partial unique indexes simple while separating lookup from path-scope client_id - Extended Tauri create/update + DTO with binding_type — required for wizard persistence and WorkspacesPage display (minimal ripple outside Phase 2 inventory) - Rebuilt partial unique indexes per binding_type in migration 037 — path and id rows can share no workspace_root collision semantics Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 830c2ec commit 9af05c0

13 files changed

Lines changed: 725 additions & 137 deletions

File tree

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

Lines changed: 94 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use std::collections::{HashMap, HashSet};
88
use std::sync::Arc;
99

1010
use mcpmux_core::{
11-
normalize_optional_metadata, validate_workspace_root as validate_root, DomainEvent, FeatureSet,
12-
FeatureSetType, MemberMode, MemberType, ServerFeature, WorkspaceBinding,
11+
normalize_optional_metadata, validate_workspace_root as validate_root, BindingType, DomainEvent,
12+
FeatureSet, FeatureSetType, MemberMode, MemberType, ServerFeature, WorkspaceBinding,
1313
WorkspaceRootValidation,
1414
};
1515
use serde::{Deserialize, Serialize};
@@ -61,6 +61,8 @@ pub struct WorkspaceBindingDto {
6161
pub space_id: String,
6262
pub feature_set_ids: Vec<String>,
6363
pub machine_id: Option<String>,
64+
#[serde(default)]
65+
pub binding_type: Option<String>,
6466
pub created_at: String,
6567
pub updated_at: String,
6668
}
@@ -75,6 +77,7 @@ impl From<WorkspaceBinding> for WorkspaceBindingDto {
7577
space_id: b.space_id.to_string(),
7678
feature_set_ids: b.feature_set_ids,
7779
machine_id: b.machine_id.map(|id| id.to_string()),
80+
binding_type: Some(b.binding_type.as_db_str().to_string()),
7881
created_at: b.created_at.to_rfc3339(),
7982
updated_at: b.updated_at.to_rfc3339(),
8083
}
@@ -95,6 +98,8 @@ pub struct WorkspaceBindingInput {
9598
pub feature_set_ids: Vec<String>,
9699
#[serde(default)]
97100
pub machine_id: Option<String>,
101+
#[serde(default)]
102+
pub binding_type: Option<String>,
98103
}
99104

100105
fn parse_optional_machine_id(value: Option<&str>) -> Result<Option<Uuid>, String> {
@@ -119,6 +124,13 @@ fn binding_scope_conflicts(
119124
existing.machine_id == machine_id && existing.client_id.as_deref() == client_id
120125
}
121126

127+
fn parse_binding_type(value: Option<&str>) -> BindingType {
128+
match value {
129+
Some("id") => BindingType::Id,
130+
_ => BindingType::Path,
131+
}
132+
}
133+
122134
fn parse_space_id(input: &WorkspaceBindingInput) -> Result<Uuid, String> {
123135
Uuid::parse_str(&input.space_id).map_err(|e| format!("bad space_id: {e}"))
124136
}
@@ -358,51 +370,74 @@ pub async fn create_workspace_binding(
358370
) -> Result<WorkspaceBindingDto, String> {
359371
let space_id = parse_space_id(&input)?;
360372
let feature_set_ids = validate_fs_list(&input)?;
361-
let normalized = normalize_and_validate(&input.workspace_root)?;
373+
let binding_type = parse_binding_type(input.binding_type.as_deref());
374+
let machine_id = parse_optional_machine_id(input.machine_id.as_deref())?;
375+
376+
let (normalized, binding) = if binding_type == BindingType::Id {
377+
let client_key = input.workspace_root.trim();
378+
if client_key.is_empty() {
379+
return Err("client id cannot be empty".into());
380+
}
381+
let mut binding = WorkspaceBinding::new_id_multi(client_key, space_id, feature_set_ids);
382+
binding.machine_id = machine_id;
383+
binding.label = resolve_binding_label(&input, None);
384+
binding.icon = normalize_optional_metadata(&input.icon);
385+
(client_key.to_string(), binding)
386+
} else {
387+
let normalized = normalize_and_validate(&input.workspace_root)?;
388+
let binding = WorkspaceBinding {
389+
id: Uuid::new_v4(),
390+
workspace_root: normalized.clone(),
391+
binding_type: BindingType::Path,
392+
client_id: None,
393+
machine_id,
394+
label: resolve_binding_label(&input, None),
395+
icon: resolve_binding_icon(&state, &normalized, &input, None).await?,
396+
space_id,
397+
feature_set_ids,
398+
created_at: chrono::Utc::now(),
399+
updated_at: chrono::Utc::now(),
400+
};
401+
(normalized, binding)
402+
};
362403

363404
// Reject a duplicate folder up front with a readable message. The schema
364405
// already enforces `UNIQUE(workspace_root)`, but that surfaces an opaque
365406
// SQLite constraint error — this gives the UI something a user can act on.
366-
let machine_id = parse_optional_machine_id(input.machine_id.as_deref())?;
367407

368408
let existing = state
369409
.workspace_binding_repository
370410
.list()
371411
.await
372412
.map_err(|e| e.to_string())?;
373-
if existing
374-
.iter()
375-
.any(|b| binding_scope_conflicts(b, &normalized, machine_id, None))
376-
{
413+
if existing.iter().any(|b| {
414+
b.binding_type == binding_type
415+
&& binding_scope_conflicts(b, &normalized, machine_id, None)
416+
}) {
417+
let noun = if binding_type == BindingType::Id {
418+
"client mapping"
419+
} else {
420+
"mapping"
421+
};
377422
return Err(format!(
378-
"A mapping already exists for {normalized}. Edit the existing mapping instead of adding a second one."
423+
"A {noun} already exists for {normalized}. Edit the existing mapping instead of adding a second one."
379424
));
380425
}
381426

382-
let binding = WorkspaceBinding {
383-
id: Uuid::new_v4(),
384-
workspace_root: normalized.clone(),
385-
client_id: None,
386-
machine_id,
387-
label: resolve_binding_label(&input, None),
388-
icon: resolve_binding_icon(&state, &normalized, &input, None).await?,
389-
space_id,
390-
feature_set_ids,
391-
created_at: chrono::Utc::now(),
392-
updated_at: chrono::Utc::now(),
393-
};
394-
395427
state
396428
.workspace_binding_repository
397429
.create(&binding)
398430
.await
399431
.map_err(|e| e.to_string())?;
400432

401-
clear_appearance_for_bound_root(&state, &normalized).await?;
433+
if binding_type == BindingType::Path {
434+
clear_appearance_for_bound_root(&state, &normalized).await?;
435+
}
402436

403437
info!(
404438
binding_id = %binding.id,
405439
root = %binding.workspace_root,
440+
?binding_type,
406441
%space_id,
407442
feature_sets = ?binding.feature_set_ids,
408443
"[workspace_binding] created",
@@ -417,8 +452,6 @@ pub async fn create_workspace_binding(
417452
Ok(binding.into())
418453
}
419454

420-
/// Update an existing binding. Accepts full input so the UI can edit any
421-
/// axis (root, target space, target FS) in one call.
422455
#[tauri::command]
423456
pub async fn update_workspace_binding(
424457
id: String,
@@ -429,9 +462,26 @@ pub async fn update_workspace_binding(
429462
let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?;
430463
let space_id = parse_space_id(&input)?;
431464
let feature_set_ids = validate_fs_list(&input)?;
432-
let normalized = normalize_and_validate(&input.workspace_root)?;
465+
let binding_type = parse_binding_type(input.binding_type.as_deref());
433466
let machine_id = parse_optional_machine_id(input.machine_id.as_deref())?;
434467

468+
let existing = state
469+
.workspace_binding_repository
470+
.get(&id_uuid)
471+
.await
472+
.map_err(|e| e.to_string())?
473+
.ok_or_else(|| format!("binding not found: {}", id))?;
474+
475+
let normalized = if binding_type == BindingType::Id {
476+
let client_key = input.workspace_root.trim();
477+
if client_key.is_empty() {
478+
return Err("client id cannot be empty".into());
479+
}
480+
client_key.to_string()
481+
} else {
482+
normalize_and_validate(&input.workspace_root)?
483+
};
484+
435485
// If the edit moved the folder onto a path another mapping already owns,
436486
// reject with a readable message rather than tripping the DB UNIQUE
437487
// constraint. Exclude this binding's own row.
@@ -440,30 +490,34 @@ pub async fn update_workspace_binding(
440490
.list()
441491
.await
442492
.map_err(|e| e.to_string())?;
443-
if all
444-
.iter()
445-
.any(|b| b.id != id_uuid && binding_scope_conflicts(b, &normalized, machine_id, None))
446-
{
493+
if all.iter().any(|b| {
494+
b.id != id_uuid
495+
&& b.binding_type == binding_type
496+
&& binding_scope_conflicts(b, &normalized, machine_id, None)
497+
}) {
447498
return Err(format!(
448499
"Another mapping already uses {normalized}. Pick a different folder."
449500
));
450501
}
451502

452-
let existing = state
453-
.workspace_binding_repository
454-
.get(&id_uuid)
455-
.await
456-
.map_err(|e| e.to_string())?
457-
.ok_or_else(|| format!("binding not found: {}", id))?;
458503
let old_space_id = existing.space_id;
459504
let previous_icon = existing.icon.clone();
460505
let client_id = existing.client_id.clone();
461506
let label = resolve_binding_label(&input, Some(&existing));
462-
let icon = resolve_binding_icon(&state, &normalized, &input, Some(&existing)).await?;
507+
let icon = if binding_type == BindingType::Id {
508+
if input.icon.is_some() {
509+
normalize_optional_metadata(&input.icon)
510+
} else {
511+
existing.icon.clone()
512+
}
513+
} else {
514+
resolve_binding_icon(&state, &normalized, &input, Some(&existing)).await?
515+
};
463516

464517
let updated = WorkspaceBinding {
465518
id: existing.id,
466519
workspace_root: normalized.clone(),
520+
binding_type,
467521
client_id,
468522
machine_id,
469523
label,
@@ -480,7 +534,9 @@ pub async fn update_workspace_binding(
480534
.await
481535
.map_err(|e| e.to_string())?;
482536

483-
clear_appearance_for_bound_root(&state, &normalized).await?;
537+
if binding_type == BindingType::Path {
538+
clear_appearance_for_bound_root(&state, &normalized).await?;
539+
}
484540

485541
if previous_icon.as_deref() != updated.icon.as_deref() {
486542
maybe_remove_orphaned_icon_file(&state, previous_icon.as_deref()).await?;

0 commit comments

Comments
 (0)