Waits for the entrypoint, uploads the payload and the sync script, runs it as claude and stores its report (payload skips merged in). The start hook spawns the sync in the background; a per-project lock serialises syncs of one project (pre-flight F11b). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
741 lines
26 KiB
Rust
741 lines
26 KiB
Rust
//! Marketplaces: git repos of agents, skills, commands, hooks and plugins that
|
|
//! are fetched on the host and synced into containers. See
|
|
//! `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
|
|
|
|
pub mod auth;
|
|
pub mod catalog;
|
|
pub mod diff;
|
|
pub mod git;
|
|
pub mod payload;
|
|
pub mod sync;
|
|
pub mod tree;
|
|
#[cfg(test)]
|
|
mod sync_script_tests;
|
|
#[cfg(test)]
|
|
pub(crate) mod test_support;
|
|
|
|
use std::collections::{BTreeSet, HashMap};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use tauri::Emitter;
|
|
use tokio::sync::oneshot;
|
|
|
|
use crate::models::marketplace::{
|
|
effective_installs, CatalogItem, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot, SyncReport,
|
|
};
|
|
use crate::models::{AppSettings, Project};
|
|
use catalog::{item_fingerprint, parse_catalog};
|
|
use tree::GitTree;
|
|
|
|
/// Emitted after every container sync, payload `{ project_id, report }`.
|
|
pub const SYNC_FINISHED_EVENT: &str = "marketplace-sync-finished";
|
|
|
|
pub struct MarketplaceManager {
|
|
data_root: PathBuf,
|
|
snapshots: Mutex<HashMap<String, MarketplaceSnapshot>>,
|
|
reports: Mutex<HashMap<String, SyncReport>>,
|
|
gh_login_cancel: tokio::sync::Mutex<Option<oneshot::Sender<()>>>,
|
|
/// Serialises writers of the bare caches (fetch, pins, cache removal) so
|
|
/// concurrent refreshes never race on gix ref locks (pre-flight F11a).
|
|
repo_lock: tokio::sync::Mutex<()>,
|
|
/// One lock per project, held for a whole `sync_project`, so a start sync
|
|
/// and Apply now never run `sync.sh` in one container at once (F11b).
|
|
sync_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
|
}
|
|
|
|
/// Project ids become file names; anything outside this set is not persisted.
|
|
fn safe_file_stem(id: &str) -> bool {
|
|
!id.is_empty()
|
|
&& id.len() <= 128
|
|
&& id
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
|
}
|
|
|
|
impl MarketplaceManager {
|
|
/// `data_root` is `<data_dir>/triple-c`.
|
|
pub fn new(data_root: PathBuf) -> Self {
|
|
Self {
|
|
data_root,
|
|
snapshots: Mutex::new(HashMap::new()),
|
|
reports: Mutex::new(HashMap::new()),
|
|
gh_login_cancel: tokio::sync::Mutex::new(None),
|
|
repo_lock: tokio::sync::Mutex::new(()),
|
|
sync_locks: Mutex::new(HashMap::new()),
|
|
}
|
|
}
|
|
|
|
pub fn data_root(&self) -> &Path {
|
|
&self.data_root
|
|
}
|
|
|
|
/// Hold while writing to any marketplace cache (fetch, `git::set_pins`,
|
|
/// removing a cache).
|
|
pub fn repo_lock(&self) -> &tokio::sync::Mutex<()> {
|
|
&self.repo_lock
|
|
}
|
|
|
|
/// The project's sync lock; see `sync_project`.
|
|
pub fn sync_lock(&self, project_id: &str) -> Arc<tokio::sync::Mutex<()>> {
|
|
self.sync_locks
|
|
.lock()
|
|
.unwrap()
|
|
.entry(project_id.to_string())
|
|
.or_default()
|
|
.clone()
|
|
}
|
|
|
|
pub fn snapshot(&self, marketplace_id: &str) -> Option<MarketplaceSnapshot> {
|
|
self.snapshots.lock().unwrap().get(marketplace_id).cloned()
|
|
}
|
|
|
|
pub fn put_snapshot(&self, snap: MarketplaceSnapshot) {
|
|
self.snapshots
|
|
.lock()
|
|
.unwrap()
|
|
.insert(snap.marketplace_id.clone(), snap);
|
|
}
|
|
|
|
pub fn remove_snapshot(&self, marketplace_id: &str) {
|
|
self.snapshots.lock().unwrap().remove(marketplace_id);
|
|
}
|
|
|
|
fn report_path(&self, project_id: &str) -> PathBuf {
|
|
self.data_root
|
|
.join("marketplace-sync")
|
|
.join(format!("{project_id}.json"))
|
|
}
|
|
|
|
pub fn report(&self, project_id: &str) -> Option<SyncReport> {
|
|
if let Some(r) = self.reports.lock().unwrap().get(project_id) {
|
|
return Some(r.clone());
|
|
}
|
|
if !safe_file_stem(project_id) {
|
|
return None;
|
|
}
|
|
let text = std::fs::read_to_string(self.report_path(project_id)).ok()?;
|
|
let report: SyncReport = serde_json::from_str(&text).ok()?;
|
|
self.reports
|
|
.lock()
|
|
.unwrap()
|
|
.insert(project_id.to_string(), report.clone());
|
|
Some(report)
|
|
}
|
|
|
|
pub fn put_report(&self, project_id: &str, report: SyncReport) {
|
|
self.reports
|
|
.lock()
|
|
.unwrap()
|
|
.insert(project_id.to_string(), report.clone());
|
|
if !safe_file_stem(project_id) {
|
|
return;
|
|
}
|
|
let path = self.report_path(project_id);
|
|
let write = || -> std::io::Result<()> {
|
|
std::fs::create_dir_all(path.parent().unwrap())?;
|
|
let tmp = path.with_extension("json.tmp");
|
|
std::fs::write(&tmp, serde_json::to_vec_pretty(&report).unwrap_or_default())?;
|
|
std::fs::rename(&tmp, &path)
|
|
};
|
|
if let Err(e) = write() {
|
|
log::warn!(
|
|
"Could not persist the marketplace sync report for {}: {}",
|
|
project_id,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Claim (`Some`) or release (`None`) the single gh-login slot. Claiming
|
|
/// fails while another login holds it.
|
|
pub async fn set_gh_login_cancel(&self, tx: Option<oneshot::Sender<()>>) -> bool {
|
|
let mut slot = self.gh_login_cancel.lock().await;
|
|
match tx {
|
|
Some(tx) => {
|
|
if slot.is_some() {
|
|
return false;
|
|
}
|
|
*slot = Some(tx);
|
|
true
|
|
}
|
|
None => {
|
|
*slot = None;
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn cancel_gh_login(&self) {
|
|
if let Some(tx) = self.gh_login_cancel.lock().await.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Head commit for a marketplace: the in-memory snapshot's, else the cache's.
|
|
pub fn head_for(mgr: &MarketplaceManager, m: &Marketplace) -> Option<String> {
|
|
mgr.snapshot(&m.id).and_then(|s| s.head_commit).or_else(|| {
|
|
git::cached_head(&git::cache_path(mgr.data_root(), &m.id))
|
|
.ok()
|
|
.flatten()
|
|
})
|
|
}
|
|
|
|
fn parse_at(repo: &Path, commit: &str) -> Result<Vec<CatalogItem>, String> {
|
|
let tree = GitTree::open(repo, commit)?;
|
|
Ok(parse_catalog(&tree))
|
|
}
|
|
|
|
/// Snapshot from the cache alone (no network): startup, and after an install
|
|
/// when nothing is in memory. `fetched_at` stays `None`.
|
|
pub fn load_cached_snapshot(
|
|
mgr: &MarketplaceManager,
|
|
marketplace: &Marketplace,
|
|
) -> MarketplaceSnapshot {
|
|
let repo = git::cache_path(mgr.data_root(), &marketplace.id);
|
|
let mut snap = MarketplaceSnapshot {
|
|
marketplace_id: marketplace.id.clone(),
|
|
..Default::default()
|
|
};
|
|
match git::cached_head(&repo) {
|
|
Ok(Some(head)) => match parse_at(&repo, &head) {
|
|
Ok(items) => {
|
|
snap.head_commit = Some(head);
|
|
snap.items = items;
|
|
}
|
|
Err(e) => snap.fetch_error = Some(format!("The cached copy could not be read: {e}")),
|
|
},
|
|
Ok(None) => {}
|
|
Err(e) => snap.fetch_error = Some(format!("The cached copy could not be read: {e}")),
|
|
}
|
|
snap
|
|
}
|
|
|
|
/// Keep the previous items and head (in memory, else from the cache) and
|
|
/// record why this refresh failed.
|
|
fn failed_snapshot(
|
|
mgr: &MarketplaceManager,
|
|
m: &Marketplace,
|
|
message: String,
|
|
) -> MarketplaceSnapshot {
|
|
let mut snap = mgr
|
|
.snapshot(&m.id)
|
|
.unwrap_or_else(|| load_cached_snapshot(mgr, m));
|
|
snap.fetch_error = Some(message);
|
|
mgr.put_snapshot(snap.clone());
|
|
snap
|
|
}
|
|
|
|
/// Refresh one marketplace: resolve the credential, fetch (blocking task, under
|
|
/// the repo lock), parse the catalog at head and store the snapshot. On failure
|
|
/// the previous items and head are kept and `fetch_error` is set.
|
|
pub async fn refresh_marketplace(
|
|
mgr: &MarketplaceManager,
|
|
settings: &AppSettings,
|
|
marketplace_id: &str,
|
|
) -> MarketplaceSnapshot {
|
|
let Some(m) = settings
|
|
.marketplaces
|
|
.iter()
|
|
.find(|m| m.id == marketplace_id)
|
|
.cloned()
|
|
else {
|
|
return MarketplaceSnapshot {
|
|
marketplace_id: marketplace_id.to_string(),
|
|
fetch_error: Some("This marketplace is no longer configured.".to_string()),
|
|
..Default::default()
|
|
};
|
|
};
|
|
let account = m
|
|
.account_id
|
|
.as_ref()
|
|
.and_then(|id| settings.marketplace_accounts.iter().find(|a| &a.id == id))
|
|
.cloned();
|
|
let cred = match &account {
|
|
Some(a) => match auth::resolve_credential(a).await {
|
|
Ok(c) => Some(c),
|
|
Err(e) => return failed_snapshot(mgr, &m, e),
|
|
},
|
|
None => None,
|
|
};
|
|
|
|
let repo = git::cache_path(mgr.data_root(), &m.id);
|
|
let (url, branch) = (m.url.clone(), m.branch.clone());
|
|
let joined = {
|
|
let _repo_guard = mgr.repo_lock.lock().await;
|
|
tokio::task::spawn_blocking(move || {
|
|
let head = git::fetch(&repo, &url, branch.as_deref(), cred)?;
|
|
let items = parse_at(&repo, &head).map_err(git::FetchError::Other)?;
|
|
Ok::<_, git::FetchError>((head, items))
|
|
})
|
|
.await
|
|
};
|
|
|
|
match joined {
|
|
Ok(Ok((head, items))) => {
|
|
let snap = MarketplaceSnapshot {
|
|
marketplace_id: m.id.clone(),
|
|
head_commit: Some(head),
|
|
fetched_at: Some(chrono::Utc::now().to_rfc3339()),
|
|
fetch_error: None,
|
|
items,
|
|
};
|
|
mgr.put_snapshot(snap.clone());
|
|
snap
|
|
}
|
|
Ok(Err(e)) => failed_snapshot(
|
|
mgr,
|
|
&m,
|
|
auth::describe_fetch_error(&e, account.as_ref(), &m.url),
|
|
),
|
|
Err(e) => failed_snapshot(mgr, &m, format!("The refresh task failed: {e}")),
|
|
}
|
|
}
|
|
|
|
fn item_changed(repo: &Path, inst: &MarketplaceInstall, head: &str) -> Result<bool, String> {
|
|
let old = GitTree::open(repo, &inst.commit)?;
|
|
let new = GitTree::open(repo, head)?;
|
|
Ok(item_fingerprint(&old, inst.kind, &inst.key)?
|
|
!= item_fingerprint(&new, inst.kind, &inst.key)?)
|
|
}
|
|
|
|
/// Every install (global + all projects) whose item fingerprint at head
|
|
/// differs from its pin. Installs whose pin is not in the cache are skipped.
|
|
pub fn compute_updates(
|
|
mgr: &MarketplaceManager,
|
|
settings: &AppSettings,
|
|
projects: &[Project],
|
|
) -> Vec<ItemUpdate> {
|
|
let mut seen = BTreeSet::new();
|
|
let mut out = Vec::new();
|
|
let all = settings
|
|
.global_marketplace_installs
|
|
.iter()
|
|
.chain(projects.iter().flat_map(|p| p.marketplace_installs.iter()));
|
|
for inst in all {
|
|
if !seen.insert((inst.item_ref(), inst.commit.clone())) {
|
|
continue;
|
|
}
|
|
let Some(m) = settings
|
|
.marketplaces
|
|
.iter()
|
|
.find(|m| m.id == inst.marketplace_id)
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(head) = head_for(mgr, m) else {
|
|
continue;
|
|
};
|
|
if head == inst.commit {
|
|
continue;
|
|
}
|
|
let repo = git::cache_path(mgr.data_root(), &m.id);
|
|
match item_changed(&repo, inst, &head) {
|
|
Ok(true) => out.push(ItemUpdate {
|
|
item: inst.item_ref(),
|
|
pinned: inst.commit.clone(),
|
|
head,
|
|
}),
|
|
Ok(false) => {}
|
|
Err(e) => log::debug!("Update check skipped for {}: {}", inst.key, e),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn project_installs(settings: &AppSettings, project: &Project) -> Vec<MarketplaceInstall> {
|
|
effective_installs(
|
|
&settings.global_marketplace_installs,
|
|
&project.marketplace_disabled,
|
|
&project.marketplace_installs,
|
|
)
|
|
}
|
|
|
|
/// Build the project's payload and sync it into its running container. The
|
|
/// report is stored (and persisted) whatever happens. Holds the project's sync
|
|
/// lock throughout, so concurrent syncs of one project run one after another.
|
|
pub async fn sync_project(
|
|
mgr: &MarketplaceManager,
|
|
settings: &AppSettings,
|
|
project: &Project,
|
|
container_id: &str,
|
|
) -> SyncReport {
|
|
let lock = mgr.sync_lock(&project.id);
|
|
let _sync_guard = lock.lock().await;
|
|
|
|
let installs = project_installs(settings, project);
|
|
let marketplaces = settings.marketplaces.clone();
|
|
let root = mgr.data_root().to_path_buf();
|
|
let built = tokio::task::spawn_blocking(move || {
|
|
payload::build_payload(&payload::PayloadInput {
|
|
installs: &installs,
|
|
marketplaces: &marketplaces,
|
|
data_root: &root,
|
|
})
|
|
})
|
|
.await
|
|
.map_err(|e| format!("Building the marketplace payload failed: {e}"))
|
|
.and_then(|r| r);
|
|
|
|
let report = match built {
|
|
Ok(p) => {
|
|
let items = p.manifest["items"].as_array().map_or(0, Vec::len);
|
|
log::debug!(
|
|
"Marketplace sync for project {}: {} item(s) in the payload, {} skipped on the host",
|
|
project.id,
|
|
items,
|
|
p.skipped.len()
|
|
);
|
|
let result = sync::sync_container(container_id, &p).await;
|
|
sync::with_payload_skips(sync::report_from_result(result), &p.skipped)
|
|
}
|
|
Err(e) => sync::report_from_result(Err(e)),
|
|
};
|
|
mgr.put_report(&project.id, report.clone());
|
|
report
|
|
}
|
|
|
|
/// A project with no items that has never been synced has nothing to add and
|
|
/// nothing to remove, so its start does not wait on a sync at all.
|
|
pub fn should_sync(mgr: &MarketplaceManager, settings: &AppSettings, project: &Project) -> bool {
|
|
!project_installs(settings, project).is_empty() || mgr.report(&project.id).is_some()
|
|
}
|
|
|
|
/// Sync in the background after a container start. The sync waits for the
|
|
/// entrypoint to finish (which can include a two-minute `claude update`), and
|
|
/// its failure must never fail the start — so the start never awaits it.
|
|
pub fn spawn_project_sync(
|
|
app: tauri::AppHandle,
|
|
mgr: Arc<MarketplaceManager>,
|
|
settings: AppSettings,
|
|
project: Project,
|
|
container_id: String,
|
|
) {
|
|
if !should_sync(&mgr, &settings, &project) {
|
|
return;
|
|
}
|
|
tauri::async_runtime::spawn(async move {
|
|
let report = sync_project(&mgr, &settings, &project, &container_id).await;
|
|
if !report.errors.is_empty() {
|
|
log::warn!(
|
|
"Marketplace sync for project {} reported errors: {:?}",
|
|
project.id,
|
|
report.errors
|
|
);
|
|
}
|
|
let _ = app.emit(
|
|
SYNC_FINISHED_EVENT,
|
|
serde_json::json!({ "project_id": project.id, "report": report }),
|
|
);
|
|
});
|
|
}
|
|
|
|
/// All commits referenced by installs, per marketplace (for `git::set_pins`).
|
|
pub fn pins_by_marketplace(
|
|
settings: &AppSettings,
|
|
projects: &[Project],
|
|
) -> HashMap<String, Vec<String>> {
|
|
let mut map: HashMap<String, BTreeSet<String>> = HashMap::new();
|
|
let all = settings
|
|
.global_marketplace_installs
|
|
.iter()
|
|
.chain(projects.iter().flat_map(|p| p.marketplace_installs.iter()));
|
|
for inst in all {
|
|
map.entry(inst.marketplace_id.clone())
|
|
.or_default()
|
|
.insert(inst.commit.clone());
|
|
}
|
|
map.into_iter()
|
|
.map(|(k, v)| (k, v.into_iter().collect()))
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::marketplace::test_support::GitFixture;
|
|
use crate::models::marketplace::{ItemKind, Marketplace, MarketplaceInstall};
|
|
|
|
fn settings_with(url: &str) -> AppSettings {
|
|
let mut s = AppSettings::default();
|
|
s.marketplaces.push(Marketplace {
|
|
id: "m1".into(),
|
|
name: "Test".into(),
|
|
url: url.into(),
|
|
branch: None,
|
|
account_id: None,
|
|
});
|
|
s
|
|
}
|
|
|
|
fn install(kind: ItemKind, key: &str, commit: &str) -> MarketplaceInstall {
|
|
MarketplaceInstall {
|
|
marketplace_id: "m1".into(),
|
|
kind,
|
|
key: key.into(),
|
|
commit: commit.into(),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_parses_the_catalog_at_head() {
|
|
let Some(fx) = GitFixture::new() else { return };
|
|
let c1 = fx.with_all_kinds();
|
|
let data = tempfile::tempdir().unwrap();
|
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
|
|
|
let snap = refresh_marketplace(&mgr, &settings_with(&fx.url()), "m1").await;
|
|
|
|
assert_eq!(snap.fetch_error, None);
|
|
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
|
|
assert!(snap.fetched_at.is_some());
|
|
let mut keys: Vec<String> = snap
|
|
.items
|
|
.iter()
|
|
.map(|i| format!("{:?}:{}", i.kind, i.key))
|
|
.collect();
|
|
keys.sort();
|
|
assert_eq!(
|
|
keys,
|
|
vec![
|
|
"Agent:code-reviewer",
|
|
"Command:example-command",
|
|
"Hook:notify-on-stop",
|
|
"Plugin:example-plugin",
|
|
"Skill:example-skill",
|
|
]
|
|
);
|
|
assert_eq!(mgr.snapshot("m1"), Some(snap));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_failure_keeps_snapshot() {
|
|
let Some(fx) = GitFixture::new() else { return };
|
|
let c1 = fx.with_all_kinds();
|
|
let url = fx.url();
|
|
let data = tempfile::tempdir().unwrap();
|
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
|
let settings = settings_with(&url);
|
|
let first = refresh_marketplace(&mgr, &settings, "m1").await;
|
|
assert_eq!(first.fetch_error, None);
|
|
|
|
drop(fx); // the source repository disappears (offline, deleted, …)
|
|
let second = refresh_marketplace(&mgr, &settings, "m1").await;
|
|
|
|
assert!(second.fetch_error.is_some(), "expected a fetch error");
|
|
assert_eq!(second.head_commit.as_deref(), Some(c1.as_str()));
|
|
assert_eq!(second.items, first.items);
|
|
assert_eq!(second.fetched_at, first.fetched_at);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_waits_for_the_repo_lock() {
|
|
let Some(fx) = GitFixture::new() else { return };
|
|
let c1 = fx.with_all_kinds();
|
|
let data = tempfile::tempdir().unwrap();
|
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
|
let settings = settings_with(&fx.url());
|
|
|
|
let guard = mgr.repo_lock().lock().await;
|
|
let blocked = tokio::time::timeout(
|
|
std::time::Duration::from_millis(300),
|
|
refresh_marketplace(&mgr, &settings, "m1"),
|
|
)
|
|
.await;
|
|
assert!(blocked.is_err(), "refresh must not fetch while the repo lock is held");
|
|
assert!(
|
|
!git::cache_path(data.path(), "m1").exists(),
|
|
"nothing may touch the cache while the lock is held"
|
|
);
|
|
drop(guard);
|
|
|
|
let snap = refresh_marketplace(&mgr, &settings, "m1").await;
|
|
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_refreshes_all_succeed() {
|
|
let Some(fx) = GitFixture::new() else { return };
|
|
let c1 = fx.with_all_kinds();
|
|
let data = tempfile::tempdir().unwrap();
|
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
|
let settings = settings_with(&fx.url());
|
|
|
|
let (a, b, c) = tokio::join!(
|
|
refresh_marketplace(&mgr, &settings, "m1"),
|
|
refresh_marketplace(&mgr, &settings, "m1"),
|
|
refresh_marketplace(&mgr, &settings, "m1"),
|
|
);
|
|
for snap in [a, b, c] {
|
|
assert_eq!(snap.fetch_error, None);
|
|
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cached_snapshot_loads_without_network() {
|
|
let Some(fx) = GitFixture::new() else { return };
|
|
let c1 = fx.with_all_kinds();
|
|
let data = tempfile::tempdir().unwrap();
|
|
let settings = settings_with(&fx.url());
|
|
{
|
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
|
refresh_marketplace(&mgr, &settings, "m1").await;
|
|
}
|
|
drop(fx);
|
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
|
let snap = load_cached_snapshot(&mgr, &settings.marketplaces[0]);
|
|
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
|
|
assert_eq!(snap.items.len(), 5);
|
|
assert_eq!(snap.fetch_error, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn only_items_whose_own_files_changed_have_updates() {
|
|
let Some(fx) = GitFixture::new() else { return };
|
|
let c1 = fx.with_all_kinds();
|
|
fx.write(
|
|
"agents/code-reviewer.md",
|
|
"---\nname: code-reviewer\ndescription: Reviews code\n---\nReview harder.\n",
|
|
);
|
|
let c2 = fx.commit("tweak agent");
|
|
let data = tempfile::tempdir().unwrap();
|
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
|
let mut settings = settings_with(&fx.url());
|
|
settings.global_marketplace_installs = vec![
|
|
install(ItemKind::Agent, "code-reviewer", &c1),
|
|
install(ItemKind::Hook, "notify-on-stop", &c1),
|
|
];
|
|
let mut project = crate::models::Project::new("p".into(), vec![]);
|
|
project.marketplace_installs = vec![install(ItemKind::Skill, "example-skill", &c1)];
|
|
refresh_marketplace(&mgr, &settings, "m1").await;
|
|
|
|
let updates = compute_updates(&mgr, &settings, &[project]);
|
|
|
|
assert_eq!(updates.len(), 1, "{updates:?}");
|
|
assert_eq!(updates[0].item.key, "code-reviewer");
|
|
assert_eq!(updates[0].pinned, c1);
|
|
assert_eq!(updates[0].head, c2);
|
|
}
|
|
|
|
#[test]
|
|
fn pins_are_grouped_and_deduplicated_per_marketplace() {
|
|
let a = "a".repeat(40);
|
|
let b = "b".repeat(40);
|
|
let mut settings = settings_with("https://example.invalid/r.git");
|
|
settings.global_marketplace_installs = vec![
|
|
install(ItemKind::Agent, "x", &b),
|
|
install(ItemKind::Hook, "y", &a),
|
|
];
|
|
let mut project = crate::models::Project::new("p".into(), vec![]);
|
|
project.marketplace_installs = vec![install(ItemKind::Agent, "z", &a)];
|
|
let pins = pins_by_marketplace(&settings, &[project]);
|
|
assert_eq!(pins.get("m1"), Some(&vec![a.clone(), b.clone()]));
|
|
}
|
|
|
|
#[test]
|
|
fn reports_are_persisted_per_project() {
|
|
let data = tempfile::tempdir().unwrap();
|
|
let report = SyncReport {
|
|
installed: vec!["agent:x".into()],
|
|
..Default::default()
|
|
};
|
|
MarketplaceManager::new(data.path().to_path_buf()).put_report("proj-1", report.clone());
|
|
let fresh = MarketplaceManager::new(data.path().to_path_buf());
|
|
assert_eq!(fresh.report("proj-1"), Some(report));
|
|
assert_eq!(fresh.report("proj-2"), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn only_one_gh_login_may_hold_the_cancel_slot() {
|
|
let mgr = MarketplaceManager::new(std::env::temp_dir());
|
|
let (tx1, rx1) = tokio::sync::oneshot::channel();
|
|
let (tx2, _rx2) = tokio::sync::oneshot::channel();
|
|
assert!(mgr.set_gh_login_cancel(Some(tx1)).await);
|
|
assert!(!mgr.set_gh_login_cancel(Some(tx2)).await);
|
|
mgr.cancel_gh_login().await;
|
|
assert!(rx1.await.is_ok(), "cancel must signal the running login");
|
|
let (tx3, _rx3) = tokio::sync::oneshot::channel();
|
|
assert!(
|
|
mgr.set_gh_login_cancel(Some(tx3)).await,
|
|
"slot is free after cancel"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_project_that_never_had_items_is_not_synced() {
|
|
let data = tempfile::tempdir().unwrap();
|
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
|
let settings = settings_with("https://example.invalid/r.git");
|
|
let project = crate::models::Project::new("p".into(), vec![]);
|
|
assert!(!should_sync(&mgr, &settings, &project));
|
|
}
|
|
|
|
#[test]
|
|
fn a_project_with_items_or_a_previous_sync_is_synced() {
|
|
let data = tempfile::tempdir().unwrap();
|
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
|
let mut settings = settings_with("https://example.invalid/r.git");
|
|
let project = crate::models::Project::new("p".into(), vec![]);
|
|
settings.global_marketplace_installs = vec![install(ItemKind::Agent, "a", &"a".repeat(40))];
|
|
assert!(should_sync(&mgr, &settings, &project), "global items apply");
|
|
|
|
// Everything was uninstalled since the last sync: the container still
|
|
// holds the old files, so it must be synced to remove them.
|
|
settings.global_marketplace_installs.clear();
|
|
mgr.put_report(&project.id, SyncReport::default());
|
|
assert!(should_sync(&mgr, &settings, &project));
|
|
}
|
|
|
|
#[test]
|
|
fn a_project_whose_only_item_is_disabled_is_not_synced() {
|
|
let data = tempfile::tempdir().unwrap();
|
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
|
let mut settings = settings_with("https://example.invalid/r.git");
|
|
let inst = install(ItemKind::Agent, "a", &"a".repeat(40));
|
|
let mut project = crate::models::Project::new("p".into(), vec![]);
|
|
project.marketplace_disabled = vec![inst.item_ref()];
|
|
settings.global_marketplace_installs = vec![inst];
|
|
assert!(!should_sync(&mgr, &settings, &project));
|
|
}
|
|
|
|
#[test]
|
|
fn sync_locks_are_per_project() {
|
|
let mgr = MarketplaceManager::new(std::env::temp_dir());
|
|
let a1 = mgr.sync_lock("a");
|
|
let a2 = mgr.sync_lock("a");
|
|
let b = mgr.sync_lock("b");
|
|
assert!(Arc::ptr_eq(&a1, &a2), "one lock per project");
|
|
assert!(!Arc::ptr_eq(&a1, &b), "projects do not block each other");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sync_project_waits_for_the_projects_sync_lock() {
|
|
// Pre-flight F11b: a start sync and Apply now must never run sync.sh
|
|
// in the same container at once.
|
|
let data = tempfile::tempdir().unwrap();
|
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
|
let settings = settings_with("https://example.invalid/r.git");
|
|
let project = crate::models::Project::new("p".into(), vec![]);
|
|
|
|
let lock = mgr.sync_lock(&project.id);
|
|
let guard = lock.lock().await;
|
|
let blocked = tokio::time::timeout(
|
|
std::time::Duration::from_millis(300),
|
|
sync_project(&mgr, &settings, &project, "no-such-container"),
|
|
)
|
|
.await;
|
|
assert!(blocked.is_err(), "sync must wait while another sync holds the lock");
|
|
assert_eq!(mgr.report(&project.id), None, "nothing ran while blocked");
|
|
drop(guard);
|
|
|
|
// No Docker (or no such container) here: the failure becomes a stored
|
|
// report instead of an error.
|
|
let report = sync_project(&mgr, &settings, &project, "no-such-container").await;
|
|
assert_eq!(report.errors.len(), 1, "{report:?}");
|
|
assert!(!report.finished_at.is_empty());
|
|
assert_eq!(mgr.report(&project.id), Some(report));
|
|
}
|
|
}
|