Skip to content

Commit 1a5eb32

Browse files
committed
feat(workspaces): remove binding inheritance — exact-match only
Per product decision, drop longest-prefix ancestor inheritance: a folder resolves only to a binding for its EXACT root, else it is unmapped. The card and the resolver now agree on exact matching (no "inherited" state). - repo: find_longest_prefix_match -> find_exact_for_roots (exact only); remove mcpmux_core::longest_prefix_match + its tests + the nested-prefix storage test. - resolver + get_workspace_effective_features: exact lookup. - frontend: revert the prefix-aware card (delete prefixMatch util + test, drop Entry.inherited + the Inherited UI) — back to exact matching. - tests: longest_prefix_wins -> no_inheritance_child_of_bound_parent_denies; storage test_find_exact_for_roots_is_exact_only. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 15cf4e4 commit 1a5eb32

10 files changed

Lines changed: 106 additions & 365 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,7 @@ pub async fn get_workspace_effective_features(
525525
// 3. Tier 1: longest-prefix workspace binding match.
526526
let binding = state
527527
.workspace_binding_repository
528-
.find_longest_prefix_match(&default_space.id, std::slice::from_ref(&normalized))
528+
.find_exact_for_roots(std::slice::from_ref(&normalized))
529529
.await
530530
.map_err(|e| e.to_string())?;
531531

apps/desktop/src/features/workspaces/WorkspacesPage.tsx

Lines changed: 52 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ import {
5151
} from '@/lib/api/featureSets';
5252
import { useSpaces } from '@/stores';
5353
import type { Space } from '@/lib/api/spaces';
54-
import { resolveRootBinding } from './prefixMatch';
5554

5655
/**
5756
* Workspaces page.
@@ -69,19 +68,12 @@ import { resolveRootBinding } from './prefixMatch';
6968
* • OFFLINE + mapped → neutral
7069
*/
7170

72-
type EntryKind = 'unmapped-live' | 'mapped-live' | 'inherited-live' | 'mapped-offline';
71+
type EntryKind = 'unmapped-live' | 'mapped-live' | 'mapped-offline';
7372
interface Entry {
7473
id: string;
7574
kind: EntryKind;
7675
root: string;
77-
/** This folder's OWN (exact) binding, if any. Drives edit/delete. */
7876
binding: WorkspaceBinding | null;
79-
/**
80-
* An ancestor binding this folder resolves through when it has no exact
81-
* binding of its own (longest-prefix inheritance — mirrors the gateway
82-
* resolver). Drives the "inherits from …" display.
83-
*/
84-
inherited: WorkspaceBinding | null;
8577
isLive: boolean;
8678
}
8779
type Selected = { mode: 'new' } | { mode: 'entry'; id: string };
@@ -149,6 +141,11 @@ export function WorkspacesPage() {
149141
}
150142
};
151143

144+
const bindingsByRoot = useMemo(() => {
145+
const m = new Map<string, WorkspaceBinding>();
146+
for (const b of bindings) m.set(b.workspace_root.toLowerCase(), b);
147+
return m;
148+
}, [bindings]);
152149
const fsById = useMemo(() => {
153150
const m = new Map<string, FeatureSet>();
154151
for (const f of featureSets) m.set(f.id, f);
@@ -171,21 +168,12 @@ export function WorkspacesPage() {
171168
const key = root.toLowerCase();
172169
if (seen.has(key)) continue;
173170
seen.add(key);
174-
// Match the gateway resolver: longest-prefix, so a folder with no
175-
// binding of its own still resolves through an ancestor's (inherited).
176-
const { exact, effective } = resolveRootBinding(root, bindings);
177-
const inherited = exact ? null : effective;
178-
const kind: EntryKind = exact
179-
? 'mapped-live'
180-
: inherited
181-
? 'inherited-live'
182-
: 'unmapped-live';
171+
const binding = bindingsByRoot.get(key) ?? null;
183172
list.push({
184-
id: exact?.id ?? `live:${root}`,
185-
kind,
173+
id: binding?.id ?? `live:${root}`,
174+
kind: binding ? 'mapped-live' : 'unmapped-live',
186175
root,
187-
binding: exact,
188-
inherited,
176+
binding,
189177
isLive: true,
190178
});
191179
}
@@ -198,33 +186,31 @@ export function WorkspacesPage() {
198186
kind: 'mapped-offline',
199187
root: b.workspace_root,
200188
binding: b,
201-
inherited: null,
202189
isLive: false,
203190
});
204191
}
205192
const rank: Record<EntryKind, number> = {
206193
'unmapped-live': 0,
207194
'mapped-live': 1,
208-
'inherited-live': 2,
209-
'mapped-offline': 3,
195+
'mapped-offline': 2,
210196
};
211197
return list.sort((a, b) => {
212198
const o = rank[a.kind] - rank[b.kind];
213199
return o !== 0 ? o : a.root.localeCompare(b.root);
214200
});
215-
}, [bindings, reportedRoots]);
201+
}, [bindings, bindingsByRoot, reportedRoots]);
216202

217203
const filtered = useMemo(() => {
218204
const q = searchQuery.trim().toLowerCase();
219205
return entries.filter((e) => {
220206
if (filter === 'live' && !e.isLive) return false;
221207
if (filter === 'unmapped' && e.kind !== 'unmapped-live') return false;
222208
if (!q) return true;
223-
// Resolve display names from the effective binding (own or inherited).
224-
const eff = e.binding ?? e.inherited;
225-
const spaceName = eff ? spaceById.get(eff.space_id)?.name ?? '' : '';
226-
const fsNames = eff
227-
? eff.feature_set_ids.map((id) => fsById.get(id)?.name ?? '').join(' ')
209+
const spaceName = e.binding ? spaceById.get(e.binding.space_id)?.name ?? '' : '';
210+
const fsNames = e.binding
211+
? e.binding.feature_set_ids
212+
.map((id) => fsById.get(id)?.name ?? '')
213+
.join(' ')
228214
: '';
229215
return (
230216
e.root.toLowerCase().includes(q) ||
@@ -377,16 +363,17 @@ export function WorkspacesPage() {
377363
{filtered.map((entry) => {
378364
const isSelected =
379365
selected?.mode === 'entry' && selected.id === entry.id;
380-
// Show the EFFECTIVE binding's Space + FeatureSet names — the
381-
// folder's own binding, or the ancestor it inherits from.
382-
// Truly-unmapped entries (no own + no inherited) show no
383-
// preview and read "Not mapped".
384-
const eff = entry.binding ?? entry.inherited;
385-
const resolvedSpaceName = eff
386-
? spaceById.get(eff.space_id)?.name
366+
// Mapped entries show their bound Space + FeatureSet names.
367+
// Unmapped entries deliberately show no preview — the card
368+
// reads "Not mapped" because the folder genuinely gets no
369+
// tools until the user maps it.
370+
const resolvedSpaceName = entry.binding
371+
? spaceById.get(entry.binding.space_id)?.name
387372
: undefined;
388-
const fsNames = eff
389-
? eff.feature_set_ids.map((id) => fsById.get(id)?.name ?? id)
373+
const fsNames = entry.binding
374+
? entry.binding.feature_set_ids.map(
375+
(id) => fsById.get(id)?.name ?? id
376+
)
390377
: [];
391378
return (
392379
<EntryCard
@@ -553,10 +540,6 @@ const CARD_TONES = {
553540
strip: 'bg-emerald-500',
554541
box: 'bg-emerald-50 text-emerald-600 ring-emerald-200/70 dark:bg-emerald-900/20 dark:text-emerald-400 dark:ring-emerald-800/50',
555542
},
556-
sky: {
557-
strip: 'bg-sky-500',
558-
box: 'bg-sky-50 text-sky-600 ring-sky-200/70 dark:bg-sky-900/20 dark:text-sky-400 dark:ring-sky-800/50',
559-
},
560543
amber: {
561544
strip: 'bg-amber-500',
562545
box: 'bg-amber-50 text-amber-600 ring-amber-200/70 dark:bg-amber-900/20 dark:text-amber-400 dark:ring-amber-800/50',
@@ -586,13 +569,9 @@ function EntryCard({
586569
? 'amber'
587570
: entry.kind === 'mapped-live'
588571
? 'emerald'
589-
: entry.kind === 'inherited-live'
590-
? 'sky'
591-
: 'neutral';
572+
: 'neutral';
592573
const t = CARD_TONES[tone];
593574
const name = folderName(entry.root);
594-
// The ancestor folder this entry inherits its mapping from (if any).
595-
const inheritedFrom = entry.inherited ? folderName(entry.inherited.workspace_root) : null;
596575

597576
return (
598577
<Card
@@ -628,7 +607,6 @@ function EntryCard({
628607
{entry.kind === 'unmapped-live' && <Pill tone="amber">Unmapped</Pill>}
629608
{entry.kind === 'mapped-offline' && <Pill tone="neutral">Offline</Pill>}
630609
{entry.kind === 'mapped-live' && <Pill tone="emerald">Live</Pill>}
631-
{entry.kind === 'inherited-live' && <Pill tone="sky">Inherited</Pill>}
632610
</div>
633611
<h3 className="truncate text-base font-semibold" title={entry.root}>
634612
{name}
@@ -643,42 +621,29 @@ function EntryCard({
643621
</div>
644622

645623
<div className="border-t border-[rgb(var(--border-subtle))] pt-4 text-xs">
646-
{entry.binding || entry.inherited ? (
647-
<div className="space-y-1">
648-
<div className="flex items-center justify-between gap-3">
649-
<span className="inline-flex min-w-0 items-center gap-1.5">
650-
<Layers className="h-3.5 w-3.5 flex-shrink-0 text-primary-500" />
651-
<span className="flex-shrink-0 text-[rgb(var(--muted))]">
652-
{entry.binding ? 'Serves' : 'Inherits'}
653-
</span>
624+
{entry.binding ? (
625+
<div className="flex items-center justify-between gap-3">
626+
<span className="inline-flex min-w-0 items-center gap-1.5">
627+
<Layers className="h-3.5 w-3.5 flex-shrink-0 text-primary-500" />
628+
<span
629+
className="truncate font-medium text-[rgb(var(--foreground))]"
630+
title={fsNames.join(', ')}
631+
>
632+
{summarizeFeatureSets(fsNames)}
633+
</span>
634+
{fsNames.length > 1 && (
654635
<span
655-
className="truncate font-medium text-[rgb(var(--foreground))]"
656-
title={fsNames.join(', ')}
636+
className="flex-shrink-0 rounded-full bg-primary-500/10 px-1.5 text-[10px] font-bold tabular-nums text-primary-600 dark:text-primary-300"
637+
title={`${fsNames.length} feature sets`}
657638
>
658-
{summarizeFeatureSets(fsNames)}
639+
{fsNames.length}
659640
</span>
660-
{fsNames.length > 1 && (
661-
<span
662-
className="flex-shrink-0 rounded-full bg-primary-500/10 px-1.5 text-[10px] font-bold tabular-nums text-primary-600 dark:text-primary-300"
663-
title={`${fsNames.length} feature sets`}
664-
>
665-
{fsNames.length}
666-
</span>
667-
)}
668-
</span>
669-
<span className="inline-flex flex-shrink-0 items-center gap-1.5 text-[rgb(var(--muted))]">
670-
<span>in</span>
671-
<Chip tone="neutral">{spaceName ?? '—'}</Chip>
672-
</span>
673-
</div>
674-
{entry.inherited && (
675-
<div
676-
className="truncate text-[11px] text-sky-600 dark:text-sky-400"
677-
title={entry.inherited.workspace_root}
678-
>
679-
via parent mapping {inheritedFrom} — map this folder to override
680-
</div>
681-
)}
641+
)}
642+
</span>
643+
<span className="inline-flex flex-shrink-0 items-center gap-1.5 text-[rgb(var(--muted))]">
644+
<span>in</span>
645+
<Chip tone="neutral">{spaceName ?? '—'}</Chip>
646+
</span>
682647
</div>
683648
) : (
684649
<span className="inline-flex items-center gap-1.5 font-medium text-amber-600 dark:text-amber-400">
@@ -697,16 +662,14 @@ function Pill({
697662
tone,
698663
}: {
699664
children: React.ReactNode;
700-
tone: 'amber' | 'emerald' | 'sky' | 'neutral';
665+
tone: 'amber' | 'emerald' | 'neutral';
701666
}) {
702667
const cls =
703668
tone === 'amber'
704669
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border-amber-200/80 dark:border-amber-800/60'
705670
: tone === 'emerald'
706671
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border-emerald-200/80 dark:border-emerald-800/60'
707-
: tone === 'sky'
708-
? 'bg-sky-50 dark:bg-sky-900/20 text-sky-700 dark:text-sky-400 border-sky-200/80 dark:border-sky-800/60'
709-
: 'bg-[rgb(var(--surface))] text-[rgb(var(--muted))] border-[rgb(var(--border-subtle))]';
672+
: 'bg-[rgb(var(--surface))] text-[rgb(var(--muted))] border-[rgb(var(--border-subtle))]';
710673
return (
711674
<span
712675
className={`inline-flex items-center px-1.5 py-0.5 rounded-md border text-[10px] font-semibold uppercase tracking-wider ${cls}`}
@@ -942,10 +905,7 @@ function InspectorPanel({
942905
<div className="flex-1 min-w-0">
943906
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
944907
{!isNew && entry?.isLive && <Pill tone="emerald">Live</Pill>}
945-
{!isNew && entry?.inherited && <Pill tone="sky">Inherited</Pill>}
946-
{!isNew && entry && !isMapped && !entry.inherited && (
947-
<Pill tone="amber">Unmapped</Pill>
948-
)}
908+
{!isNew && entry && !isMapped && <Pill tone="amber">Unmapped</Pill>}
949909
{!isNew && entry && isMapped && !entry.isLive && <Pill tone="neutral">Offline</Pill>}
950910
</div>
951911
<h2 className="text-lg font-bold truncate">{title}</h2>
@@ -976,9 +936,7 @@ function InspectorPanel({
976936
mode === 'create'
977937
? 'Choose the folder and the tools it should get.'
978938
: mode === 'create-from-live'
979-
? entry?.inherited
980-
? 'Inherits a parent mapping — save here to override it for this folder.'
981-
: 'This folder is open in an app but has no tools yet — map it.'
939+
? 'This folder is open in an app but has no tools yet — map it.'
982940
: isMapped && entry?.binding
983941
? `Gives ${
984942
formatFsList(
@@ -995,23 +953,6 @@ function InspectorPanel({
995953
headerExtra={<SaveStatusPill status={saveStatus} />}
996954
testId="workspace-mapping-section"
997955
>
998-
{entry?.inherited && !isMapped && (
999-
<div
1000-
className="mb-4 rounded-lg border border-sky-200 bg-sky-50 px-3.5 py-3 text-xs leading-relaxed text-sky-800 dark:border-sky-800/60 dark:bg-sky-900/20 dark:text-sky-300"
1001-
data-testid="workspace-inherited-note"
1002-
>
1003-
<span className="font-semibold">Inherited mapping.</span> This folder
1004-
has no mapping of its own — it currently resolves through its parent{' '}
1005-
<code className="font-mono">{entry.inherited.workspace_root}</code> (
1006-
{formatFsList(
1007-
entry.inherited.feature_set_ids.map(
1008-
(id) => featureSets.find((f) => f.id === id)?.name ?? id
1009-
)
1010-
) || 'no tools'}
1011-
). Saving below creates a mapping for <em>this</em> folder that
1012-
overrides the inherited one.
1013-
</div>
1014-
)}
1015956
<BindingForm
1016957
mode={mode}
1017958
spaces={spaces}

apps/desktop/src/features/workspaces/prefixMatch.ts

Lines changed: 0 additions & 42 deletions
This file was deleted.

crates/mcpmux-core/src/domain/mod.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,5 @@ pub use server_feature::*;
3838
pub use server_log::*;
3939
pub use space::*;
4040
pub use workspace_binding::{
41-
longest_prefix_match, normalize_workspace_root, validate_workspace_root, WorkspaceBinding,
42-
WorkspaceRootValidation,
41+
normalize_workspace_root, validate_workspace_root, WorkspaceBinding, WorkspaceRootValidation,
4342
};

0 commit comments

Comments
 (0)