Marketplace: sync projects into their containers on start

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>
This commit is contained in:
2026-09-27 09:30:58 -07:00
co-authored by Claude Opus 5.5
parent 1168a0c56b
commit 310d55eb37
4 changed files with 442 additions and 6 deletions
+179 -2
View File
@@ -16,12 +16,13 @@ pub(crate) mod test_support;
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use tauri::Emitter;
use tokio::sync::oneshot;
use crate::models::marketplace::{
CatalogItem, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot, SyncReport,
effective_installs, CatalogItem, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot, SyncReport,
};
use crate::models::{AppSettings, Project};
use catalog::{item_fingerprint, parse_catalog};
@@ -38,6 +39,9 @@ pub struct MarketplaceManager {
/// 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.
@@ -58,6 +62,7 @@ impl MarketplaceManager {
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()),
}
}
@@ -71,6 +76,16 @@ impl MarketplaceManager {
&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()
}
@@ -329,6 +344,93 @@ pub fn compute_updates(
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,
@@ -560,4 +662,79 @@ mod tests {
"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));
}
}