fix(plugins): a marketplace catalog is its source, not the name an add happened to use

`/plugin marketplace add <name> <path>` refused an id that already existed, so
re-adding the codewhale marketplace to refresh it was impossible once the name
was taken — and the workaround is visible in this machine's own state: two
snapshots of `codewhale-plugin-marketplace/marketplace.json`, the second one
hand-named `cw2`, both stale, sitting side by side.

This is the identity half of the grokbuild model
(`xai-grok-plugin-marketplace`'s `MarketplaceSource::identity`: the URL or
expanded path is the stable identity; the display name is not):

- `add` now keys on the canonical source document: re-adding the same source
  updates that catalog in place and renames it to the requested id when the
  name differs. A *different* source under a taken name still refuses, so a
  name never silently re-points.
- `load` collapses catalogs that share one source, keeping the entry named
  after the document itself (else the most recently added). Older states
  self-heal on the next read; the projection persists on the next write.

What this does not do: it does not refresh a catalog when its local document
changed on disk — that is the next slice (re-parse on add is a refresh today).
And it does not yet split catalog *entry kinds* (plugin vs skill), which is
what makes the skill-name suppression deletable.

Evidence: marketplace filter "44 passed; 0 failed", including the un-staled
command roundtrip (same-source re-add succeeds; different-source clash is
refused) and new store tests for rename-in-place, clash refusal, and the
two-name collapse; clippy (CI's invocation, tui lib) clean.
This commit is contained in:
CodeWhale Bot committed 2026-09-17 14:00:33 -07:00
1 parent 341151fff1
commit 751ea338f2
2 files changed
+212 -6

No files matched your search

@@ -198,13 +198,39 @@ fn marketplace_add_list_show_remove_roundtrip() {
"list/show must not rewrite marketplace state"
);
// duplicate name is refused
let dup = plugins_with_kimi_home_override(
// Re-adding the same source is a refresh, not a duplicate: a catalog is
// keyed by its document, so the second add updates it in place. (The old
// refusal here is what produced a second snapshot of one marketplace
// under a hand-made name.)
let refreshed = plugins_with_kimi_home_override(
&mut app,
Some(&format!("marketplace add kimi {}", catalog_path.display())),
None,
);
assert!(dup.is_error);
assert!(!refreshed.is_error, "{:?}", refreshed.message);
// A *different* source under the same name is still refused.
let other_catalog = catalogs.join("other-marketplace.json");
fs::write(
&other_catalog,
serde_json::to_string_pretty(&serde_json::json!({
"version": "2",
"plugins": [
{
"id": "other-bundle",
"source": "./other-bundle",
"displayName": "Other Bundle"
}
]
}))
.unwrap(),
)
.unwrap();
let clash = plugins_with_kimi_home_override(
&mut app,
Some(&format!("marketplace add kimi {}", other_catalog.display())),
None,
);
assert!(clash.is_error, "{:?}", clash.message);
let removed = plugins_with_kimi_home_override(&mut app, Some("marketplace remove kimi"), None);
assert!(!removed.is_error, "{:?}", removed.message);
+183 -3
View File
@@ -130,23 +130,46 @@ impl MarketplaceStore {
self.path.display()
));
}
Ok(state)
Ok(collapse_same_source_catalogs(state))
}
/// Insert a catalog under `id`, refusing to replace an existing entry.
/// Insert a catalog under `id`, keyed by its source document.
///
/// A catalog *is* its source: re-adding the same document updates that
/// catalog in place — and renames it to the requested `id` when the name
/// differs — instead of colliding on whatever name the previous add chose.
/// That collision is what produced a second, stale snapshot of the
/// codewhale marketplace under a hand-made name ("cw2") sitting beside
/// `codewhale`, two copies of one source. An existing *different* source
/// under `id` still refuses, so a name never silently re-points.
pub fn add(
&self,
id: &MarketplaceCatalogId,
entry: StoredMarketplaceCatalog,
) -> Result<(), String> {
self.mutate(|state| {
if state.catalogs.contains_key(id.as_str()) {
let source = canonical_source_path(&entry.source_path);
if let Some(existing) = state.catalogs.get(id.as_str())
&& canonical_source_path(&existing.source_path) != source
{
return Err(format!(
"a marketplace named `{}` already exists; /plugin marketplace remove {} first",
id.as_str(),
id.as_str()
));
}
let duplicates: Vec<String> = state
.catalogs
.iter()
.filter(|(key, catalog)| {
key.as_str() != id.as_str()
&& canonical_source_path(&catalog.source_path) == source
})
.map(|(key, _)| key.clone())
.collect();
for key in duplicates {
state.catalogs.remove(&key);
}
state.catalogs.insert(id.as_str().to_string(), entry);
Ok(())
})
@@ -184,6 +207,66 @@ impl MarketplaceStore {
}
}
/// Canonical form of a catalog's source document, for source-identity
/// comparison. This is the identity the store keys updates and duplicate
/// detection on (the marketplace sibling of grokbuild's
/// `MarketplaceSource::identity`). Falls back to the stored string when the
/// path is not readable as a file — a GitHub URL, or a document that has
/// since moved away.
fn canonical_source_path(path: &str) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path))
}
/// Collapse catalogs that point at the same source document.
///
/// Older states can hold one marketplace twice under two names (that is what
/// `"cw2"` was: a second snapshot of the same document, taken only because the
/// first name was already in use). Every surface should see one catalog per
/// source. The survivor is the entry whose key is the document's own declared
/// name when one exists, otherwise the most recently added; the projection is
/// in-memory, and the next write persists it.
fn collapse_same_source_catalogs(mut state: MarketplaceState) -> MarketplaceState {
let mut groups: BTreeMap<PathBuf, Vec<String>> = BTreeMap::new();
for (key, catalog) in &state.catalogs {
groups
.entry(canonical_source_path(&catalog.source_path))
.or_default()
.push(key.clone());
}
for keys in groups.values() {
if keys.len() < 2 {
continue;
}
let named_after_document = keys.iter().find(|key| {
state
.catalogs
.get(key.as_str())
.is_some_and(|catalog| catalog.catalog.name == key.as_str())
});
let keeper = named_after_document.cloned().or_else(|| {
keys.iter()
.max_by(|left, right| {
let added = |key: &String| {
state
.catalogs
.get(key.as_str())
.map(|catalog| catalog.added_at.as_str())
.unwrap_or_default()
};
added(left).cmp(added(right))
})
.cloned()
});
let Some(keeper) = keeper else { continue };
for key in keys {
if *key != keeper {
state.catalogs.remove(key);
}
}
}
state
}
fn first_party_catalog() -> Result<StoredMarketplaceCatalog, String> {
#[derive(Deserialize)]
struct Snapshot {
@@ -275,4 +358,101 @@ mod tests {
assert!(store.load().is_err());
assert_eq!(fs::read_to_string(store.path()).unwrap(), "broken");
}
fn catalog_from_source(source: &Path) -> StoredMarketplaceCatalog {
let mut catalog = first_party_catalog().unwrap();
catalog.source_path = source.display().to_string();
catalog
}
/// `add` keys on the source document, not the name the previous add used:
/// re-adding the same marketplace under its real name replaces the
/// hand-made duplicate instead of colliding or leaving both behind.
#[test]
fn re_adding_the_same_source_renames_in_place() {
let root = tempfile::tempdir().unwrap();
let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap();
let document = root.path().join("marketplace.json");
fs::write(&document, "{}").unwrap();
store
.add(
&MarketplaceCatalogId::new("cw2"),
catalog_from_source(&document),
)
.unwrap();
store
.add(
&MarketplaceCatalogId::new("codewhale"),
catalog_from_source(&document),
)
.unwrap();
let state = store.load().unwrap();
assert!(state.get("cw2").is_none(), "the duplicate name is gone");
assert_eq!(
state.get("codewhale").unwrap().source_path,
document.display().to_string()
);
}
/// A name is never silently re-pointed at a different source.
#[test]
fn a_name_never_re_points_at_a_different_source() {
let root = tempfile::tempdir().unwrap();
let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap();
let first = root.path().join("first.json");
let second = root.path().join("second.json");
fs::write(&first, "{}").unwrap();
fs::write(&second, "{}").unwrap();
store
.add(
&MarketplaceCatalogId::new("codewhale"),
catalog_from_source(&first),
)
.unwrap();
let refused = store
.add(
&MarketplaceCatalogId::new("codewhale"),
catalog_from_source(&second),
)
.expect_err("a different source under a taken name must refuse");
assert!(refused.contains("already exists"), "{refused}");
assert_eq!(
store.load().unwrap().get("codewhale").unwrap().source_path,
first.display().to_string()
);
}
/// A state written before source identity was keyed can hold one source
/// twice under two names; the projection collapses it to the entry named
/// after the document itself.
#[test]
fn load_collapses_one_source_stored_under_two_names() {
let root = tempfile::tempdir().unwrap();
let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap();
let document = root.path().join("marketplace.json");
fs::write(&document, "{}").unwrap();
let mut state = MarketplaceState::default();
for (key, added_at) in [
("codewhale", "2026-09-02T00:00:00Z"),
("cw2", "2026-09-16T00:00:00Z"),
] {
let mut catalog = catalog_from_source(&document);
catalog.added_at = added_at.to_string();
state.catalogs.insert(key.to_string(), catalog);
}
ensure_private_plugin_state_directory(store.path().parent().unwrap()).unwrap();
fs::write(store.path(), serde_json::to_string(&state).unwrap()).unwrap();
let loaded = store.load().unwrap();
assert!(loaded.get("cw2").is_none());
assert!(loaded.get("codewhale").is_some());
assert_eq!(
loaded.get("codewhale").unwrap().source_path,
document.display().to_string()
);
}
}