Skip to main content

bamboo_server/
plugin_installer.rs

1//! `ServerPluginInstaller` — the `AppState`-backed implementation of
2//! `bamboo_plugin::PluginInstaller` (Wave 2 § Installer-core agent,
3//! `PLUGIN_PLAN.md`).
4//!
5//! `bamboo-plugin` is an `infra`-layer crate with no access to `AppState`, so
6//! its `LocalPluginInstaller` reference skeleton stops at
7//! `PluginError::NotImplemented` exactly where capability registration needs
8//! `config.json`, `mcp_manager`, `prompt-presets.json`, and plugin discovery
9//! roots. This type is the real implementation: an ordinary
10//! downstream `impl PluginInstaller for ServerPluginInstaller` (the trait is
11//! foreign, the type is local — no orphan-rule issue).
12//!
13//! # Why a borrowed `web::Data<AppState>` and no `AppState` struct change
14//!
15//! `ServerPluginInstaller` holds a `web::Data<AppState>` clone — the exact
16//! handle every HTTP handler in this crate already receives as an argument
17//! (`web::Data` is `Arc`-backed, so cloning it is cheap). An HTTP handler
18//! constructs one per request: `ServerPluginInstaller::new(state.clone())`.
19//! `AppState` itself is intentionally untouched — no new field, no
20//! coordinated append to `app_state/mod.rs` / `app_state/builder.rs` — so
21//! this branch can never conflict with the other Wave-2 branches that also
22//! stack on `feat/plugin-framework`.
23//!
24//! # Path derivation: `state.app_data_dir`, not the `bamboo_config::paths` globals
25//!
26//! `bamboo_config::paths::{plugins_dir, workflows_dir, plugins_installed_json_path, ...}`
27//! all resolve through a process-wide `OnceLock` that `AppState::new` seeds
28//! ONCE per process (first caller wins — see its doc comment). That is
29//! correct for the single production `AppState` per process, but this
30//! crate's own test suite already builds many `AppState`s over different
31//! `tempfile::tempdir()`s in the same test binary (e.g.
32//! `app_state::tests::test_app_state_creation` and friends) — if this type
33//! read the global helpers, every one of those `AppState`s would silently
34//! share whichever tempdir happened to construct the first one. Every path
35//! below is instead derived from the borrowed `state.app_data_dir` field
36//! directly, exactly the pattern `handlers::settings::workflows` and
37//! `prompt_presets::storage::store_file_path` already use. In production,
38//! where there is exactly one `AppState`, this resolves to the identical
39//! path the global helpers would have produced.
40//!
41//! # Concurrency
42//!
43//! Every `install`/`uninstall` runs under a single process-wide async lock
44//! ([`PLUGIN_OP_LOCK`]), held for the ENTIRE operation including rollback, so
45//! the reconcile→mutate→provenance sequence is atomic w.r.t. any other plugin
46//! op. This closes three concurrency gaps at once: the `installed.json` and
47//! `prompt-presets.json` load/modify/save lost-update races, and the MCP
48//! reconcile→config-write TOCTOU. As additional defense against a concurrent
49//! NON-plugin config write (which does not take this lock), the MCP step also
50//! RE-runs its ownership pre-check INSIDE the `update_config` closure, under
51//! `config_io_lock`, and aborts rather than clobbering if a foreign entry
52//! appeared. Lock ordering is `PLUGIN_OP_LOCK` → `config_io_lock` (never the
53//! reverse) — see [`PLUGIN_OP_LOCK`].
54//!
55//! Plugin workflow markdown is never copied into the user's global workflow
56//! directory. It remains inside the plugin bundle and is discovered in place
57//! by the SkillStore, so plugin install cannot overwrite a same-named user
58//! source and needs no shared workflow-file lock.
59//!
60//! # Crash safety (process killed mid-install)
61//!
62//! In-process rollback (below) only fires on an `Err`. A HARD kill after the
63//! MCP step wrote to `config.json` but before provenance is committed would,
64//! without a journal, leave: `reconcile_exclusive` seeing the orphaned mcp id
65//! as existing-but-not-owned → a false `Conflict` on the retry, AND
66//! `uninstall` returning `NotFound` (no provenance) → the user stuck
67//! hand-editing `config.json`. To prevent that, `install` writes a provenance
68//! row with status [`PluginInstallStatus::Installing`] — recording the
69//! INTENDED ownership set — BEFORE steps 1-4, and flips it to
70//! [`PluginInstallStatus::Installed`] only after step 5 succeeds. On the next
71//! install/upgrade of an id whose row is still `Installing` (a prior crash),
72//! [`load_previous_for_disposition`] returns it as `previous` (it does NOT
73//! trip `AlreadyInstalled`), so its intended set is treated as
74//! this-plugin-owned — the leftover reads as an `OwnedReinstall`, not a
75//! foreign conflict — and is cleaned up as an upgrade-over-incomplete.
76//! `uninstall` works on an `Installing` row too.
77//!
78//! # Atomicity / rollback semantics
79//!
80//! `install()` follows `PLUGIN_PLAN.md`'s numbered sequence exactly:
81//!
82//! 0. **Upgrade drop-diff** (only when upgrading an already-installed id):
83//!    de-register whatever the new manifest no longer declares, computed via
84//!    [`bamboo_plugin::registry::RegisteredCapabilities::removed_since`],
85//!    BEFORE registering anything new. De-registration is idempotent/
86//!    best-effort (see [`ServerPluginInstaller::deregister_capabilities`]) —
87//!    an entry a user already removed by hand never blocks an upgrade.
88//! 1. **MCP** — ownership-checked (REFUSE on a foreign conflict, via
89//!    [`bamboo_plugin::registry::reconcile_exclusive`]), merged into
90//!    `config.json`, started.
91//! 2. **Prompts** — rename-on-collision (never refuse), appended to
92//!    `prompt-presets.json`.
93//! 3. **Workflows** — validated for safe in-place discovery; no shared-store
94//!    copy or ownership mutation.
95//! 4. **Skills** — nothing to register (discovered in place); just recorded.
96//! 5. **Provenance commit** — `installed.json` is only ever upserted after
97//!    steps 0-4 all succeed.
98//!
99//! Steps 1-2 are real, sequential mutations (config then prompt-store writes)
100//! — NOT a dry-run computed up front — because `PLUGIN_PLAN.md`
101//! requires the ownership pre-checks to run in that exact order against the
102//! LIVE state each step leaves behind. That means a HARD failure at step 2
103//! or the workflow validation in step 3 can happen after step 1 already wrote
104//! real entries into `config.json`. [`ServerPluginInstaller::install`] tracks
105//! every already-applied mutation in an [`InstallRollback`] and, on any hard
106//! failure from steps 1-3, best-effort UNDOES them (removes the mcp entries
107//! it just added and stops any it started, removes the presets it just
108//! appended, and removes services it started) before returning the
109//! error — so a caller's retry starts from a clean slate. Provenance is
110//! never written on a failed path (step 5 is the only place `installed.json`
111//! is touched on success), which is the minimum safety bar even if a rollback
112//! step itself only partially succeeds (rollback operations are themselves
113//! idempotent/log-and-continue, so a second rollback attempt via a plain
114//! retry can never fail louder than the first).
115//!
116//! One known, accepted gap: `stage_plugin_source`/`install_plugin_from_source`
117//! in [`crate::plugin_source`] additionally guard the ON-DISK `plugin_dir`
118//! swap itself (an upgrade's new bundle replaces the old one's files at a
119//! fixed path) by moving the previous bundle aside instead of deleting it, and
120//! restoring it if `install()` subsequently fails — see that module's docs.
121//!
122//! Prompt-preset drop-diff caveat: the upgrade drop-diff compares the NEW
123//! manifest's nominal preset ids against the OLD install's ACTUAL (possibly
124//! renamed-on-collision) registered ids. A preset that got renamed at its
125//! original install time and is still declared under its original nominal id
126//! in the new manifest will look "dropped" (the nominal id is absent from the
127//! actual old set) and get re-appended (possibly renamed again). This is
128//! harmless — preset content is just refreshed under a fresh id — and not
129//! worth a stable-id-mapping schema change for what `RegisteredCapabilities`
130//! already documents as the one rename (not refuse) exception.
131
132use std::collections::HashSet;
133use std::path::{Path, PathBuf};
134
135use async_trait::async_trait;
136use chrono::{DateTime, Utc};
137use tokio::fs;
138
139use bamboo_domain::mcp_config::McpServerConfig;
140use bamboo_plugin::installer::{load_previous_for_disposition, preflight_install};
141use bamboo_plugin::manifest::{Platform, ServiceManifestEntry};
142use bamboo_plugin::registry::{reconcile_exclusive, RegisteredCapabilities};
143use bamboo_plugin::{
144    InstallDisposition, InstalledPlugin, InstalledPlugins, PluginError, PluginInstallStatus,
145    PluginInstaller, PluginManifest, PluginResult, PluginSource,
146};
147
148use crate::app_state::{AppState, ConfigUpdateEffects};
149use crate::error::AppError;
150use crate::handlers::agent::mcp::upsert_server_by_id;
151use crate::handlers::agent::prompt_presets::{
152    ensure_unique_preset_id, load_store, save_store, store_file_path, StoredPromptPreset,
153};
154use crate::service_manager::{ServiceManager, ServiceRuntimeConfig};
155
156/// Process-wide serialization of plugin install/uninstall operations.
157///
158/// The whole ownership/upgrade machinery is a read-modify-write over shared
159/// stores (`config.json`, `prompt-presets.json`, `installed.json`) with the
160/// ownership pre-check and the eventual mutation in separate steps. Under
161/// CONCURRENT plugin ops (the HTTP agent will expose exactly that) those
162/// interleave badly: two installs of different ids race `installed.json`'s
163/// load/add/save (last save drops the other's row), `prompt-presets.json`'s
164/// load/save (lost update), and the MCP reconcile→write window (a foreign
165/// entry landing mid-window gets clobbered AND recorded as plugin-owned,
166/// re-opening BLOCKER-1). Plugin installs are rare and not perf-sensitive, so
167/// one coarse process-wide lock held across the ENTIRE `install`/`uninstall`
168/// (including rollback) is the right call — it makes each op's
169/// reconcile→mutate→provenance sequence atomic w.r.t. every other plugin op.
170///
171/// Lock ordering: this lock is acquired at the TOP of `install`/`uninstall`,
172/// OUTSIDE any `AppState::update_config` call (which internally takes
173/// `config_io_lock`). So the order is always `PLUGIN_OP_LOCK` →
174/// `config_io_lock`, never the reverse — no deadlock. Nothing acquires
175/// `PLUGIN_OP_LOCK` while holding `config_io_lock`.
176///
177/// # Single-process assumption (deferred: no cross-process lock)
178///
179/// This is a `tokio::sync::Mutex` — IN-PROCESS only. It serializes plugin ops
180/// within one `bamboo serve` process, but two SEPARATE `bamboo serve`
181/// processes pointed at the same `~/.bamboo` data dir would each get their
182/// own independent `PLUGIN_OP_LOCK` and could race each other's
183/// reconcile→mutate→provenance sequence exactly the way this lock exists to
184/// prevent for concurrent ops WITHIN one process. The plugin system assumes
185/// the normal deployment: a SINGLE `bamboo serve` per data directory. True
186/// multi-process safety would need an OS-level file lock (e.g. `flock` on a
187/// lockfile under `plugins_dir()`) instead of/in addition to this `Mutex`;
188/// that's a documented follow-up, not implemented here.
189static PLUGIN_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
190
191/// AppState-backed [`PluginInstaller`]. See the module docs for the full
192/// design rationale (borrowing, path derivation, atomicity).
193pub struct ServerPluginInstaller {
194    state: actix_web::web::Data<AppState>,
195}
196
197/// Mutations already applied by a not-yet-committed `install()`, so a hard
198/// failure partway through steps 1-3 can best-effort undo exactly what has
199/// been done so far. See the module docs' "Atomicity / rollback semantics".
200#[derive(Default)]
201struct InstallRollback {
202    mcp_ids_added: Vec<String>,
203    preset_ids_added: Vec<String>,
204    /// Service ids this install claimed ownership of (whether or not the
205    /// actual `start_service` call succeeded — best-effort).
206    service_ids_added: Vec<String>,
207    /// Subset of `service_ids_added` that actually got a running
208    /// `ServiceManager` runtime started — only these need `stop_service` on
209    /// rollback.
210    service_ids_started: Vec<String>,
211}
212
213impl ServerPluginInstaller {
214    pub fn new(state: actix_web::web::Data<AppState>) -> Self {
215        Self { state }
216    }
217
218    fn plugins_dir(&self) -> PathBuf {
219        self.state.app_data_dir.join("plugins")
220    }
221
222    fn installed_json_path(&self) -> PathBuf {
223        self.plugins_dir().join("installed.json")
224    }
225
226    fn workflows_dir(&self) -> PathBuf {
227        self.state.app_data_dir.join("workflows")
228    }
229
230    fn prompt_presets_path(&self) -> PathBuf {
231        store_file_path(&self.state.app_data_dir)
232    }
233
234    /// `<data_dir>/plugin_service_config/<plugin_id>/config.json` — the
235    /// per-service user config path passed to services as
236    /// `BAMBOO_PLUGIN_SERVICE_CONFIG` (issue #479 open question 2).
237    ///
238    /// Deliberately NOT under `plugins_dir()/<plugin_id>/` (the
239    /// swap-managed `plugin_dir`): `plugin_source::stage_plugin_source`
240    /// upgrades a plugin by renaming the ENTIRE old `plugin_dir` aside and
241    /// swapping a freshly-staged directory into its place — any file living
242    /// inside `plugin_dir` would be swept away with the old bundle on
243    /// upgrade (or deleted outright on uninstall) unless bamboo specifically
244    /// carried it forward, which it does not. A sibling directory, named
245    /// only by `plugin_id`, is untouched by that swap and by
246    /// `uninstall`'s `remove_dir_all(plugin_dir)` — so a service's own
247    /// config (which may carry tokens/secrets a connector needs) survives
248    /// both an upgrade and an uninstall. bamboo only ever creates the PARENT
249    /// directory here (see [`Self::ensure_service_config_parent_dir`]) —
250    /// never writes or deletes `config.json` itself; on uninstall it is
251    /// deliberately left in place (not part of `remove_dir_all`), so a
252    /// later re-install of the same plugin id picks its old config back up
253    /// automatically.
254    fn service_config_path(&self, plugin_id: &str) -> PathBuf {
255        service_config_path_under(&self.state.app_data_dir, plugin_id)
256    }
257
258    async fn ensure_service_config_parent_dir(&self, plugin_id: &str) -> PluginResult<()> {
259        let path = self.service_config_path(plugin_id);
260        if let Some(parent) = path.parent() {
261            fs::create_dir_all(parent).await?;
262        }
263        Ok(())
264    }
265
266    /// Every service id currently owned by any OTHER installed plugin — the
267    /// "existing" side of [`reconcile_exclusive`] for services. There is no
268    /// single shared document analogous to `config.json` for services, so
269    /// provenance itself is the source of truth for "who owns this id".
270    ///
271    /// `exclude_plugin_id` MUST be the id of the plugin currently being
272    /// installed/upgraded and is always excluded — NOT an optional
273    /// nicety: by the time [`Self::register_services`] calls this,
274    /// `install()`'s crash-safety journal write has ALREADY upserted THIS
275    /// plugin's `Installing` row into `installed.json` with its full
276    /// INTENDED `service_ids` (see `install()`'s "Crash-safety journal"
277    /// step, which runs before every registration step). Without excluding
278    /// it, a plain fresh install would see its own not-yet-committed row as
279    /// a foreign owner of its own declared ids and refuse itself.
280    /// `previously_owned` (computed separately, from `previous` BEFORE that
281    /// journal write) is what still correctly classifies an upgrade's
282    /// re-declared ids as `OwnedReinstall` rather than `New`.
283    async fn existing_service_ids(&self, exclude_plugin_id: &str) -> PluginResult<Vec<String>> {
284        let store = InstalledPlugins::load(&self.installed_json_path()).await?;
285        Ok(store
286            .list()
287            .iter()
288            .filter(|plugin| plugin.id != exclude_plugin_id)
289            .flat_map(|plugin| plugin.registered.service_ids.iter().cloned())
290            .collect())
291    }
292
293    /// Resolve one manifest-declared service entry into a
294    /// [`ServiceRuntimeConfig`] ready for `ServiceManager::start_service`.
295    fn resolve_service_config(
296        &self,
297        plugin_id: &str,
298        entry: &ServiceManifestEntry,
299        plugin_dir: &Path,
300        platform: Platform,
301    ) -> ServiceRuntimeConfig {
302        resolve_service_config_under(
303            &self.state.app_data_dir,
304            plugin_id,
305            entry,
306            plugin_dir,
307            platform,
308        )
309    }
310
311    // --- De-registration primitives (shared by upgrade drop-diff, install
312    // rollback, and `uninstall`). Each is individually idempotent/tolerant —
313    // an entry that is already gone (e.g. a user manually deleted it) is
314    // logged and skipped, never a hard failure — matching the requirement
315    // that de-registration never blocks an uninstall/upgrade retry. ---
316
317    async fn remove_mcp_server(&self, id: &str) {
318        let owned_id = id.to_string();
319        let result = self
320            .state
321            .update_config(
322                move |config| {
323                    config.mcp.servers.retain(|server| server.id != owned_id);
324                    Ok(())
325                },
326                ConfigUpdateEffects {
327                    reload_provider: bamboo_config::patch::ReloadMode::None,
328                    reconcile_mcp: bamboo_config::patch::ReloadMode::BestEffort,
329                },
330            )
331            .await;
332        if let Err(error) = result {
333            tracing::warn!(
334                mcp_server_id = %id,
335                %error,
336                "failed to remove plugin-owned mcp server from config.json; continuing"
337            );
338        }
339    }
340
341    async fn remove_prompt_preset(&self, preset_id: &str) {
342        let path = self.prompt_presets_path();
343        match load_store(&path).await {
344            Ok(mut store) => {
345                let before = store.prompts.len();
346                store.prompts.retain(|preset| preset.id != preset_id);
347                if store.prompts.len() != before {
348                    if let Err(error) = save_store(&path, &store).await {
349                        tracing::warn!(
350                            %preset_id,
351                            %error,
352                            "failed to persist prompt-presets.json after removing plugin-owned preset; continuing"
353                        );
354                    }
355                }
356            }
357            Err(error) => {
358                tracing::warn!(
359                    %preset_id,
360                    %error,
361                    "failed to load prompt-presets.json while removing plugin-owned preset; continuing"
362                );
363            }
364        }
365    }
366
367    async fn remove_service(&self, id: &str) {
368        if let Err(error) = self.state.service_manager.stop_service(id).await {
369            tracing::warn!(
370                service_id = %id,
371                %error,
372                "failed to stop plugin-owned service; continuing"
373            );
374        }
375    }
376
377    async fn remove_workflow_file(&self, filename: &str) {
378        let path = self.workflows_dir().join(filename);
379        match fs::remove_file(&path).await {
380            Ok(()) => {}
381            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
382            Err(error) => {
383                tracing::warn!(
384                    %filename,
385                    %error,
386                    "failed to remove plugin-owned workflow file; continuing"
387                );
388            }
389        }
390    }
391
392    /// De-register a whole [`RegisteredCapabilities`] set (used for the
393    /// upgrade drop-diff and for `uninstall`). Skill dirs need no shared-store
394    /// action — they are only ever removed by deleting `plugin_dir` itself.
395    async fn deregister_capabilities(&self, registered: &RegisteredCapabilities) {
396        for mcp_id in &registered.mcp_server_ids {
397            self.remove_mcp_server(mcp_id).await;
398        }
399        for preset_id in &registered.preset_ids {
400            self.remove_prompt_preset(preset_id).await;
401        }
402        for workflow_filename in &registered.workflow_filenames {
403            self.remove_workflow_file(workflow_filename).await;
404        }
405        for service_id in &registered.service_ids {
406            self.remove_service(service_id).await;
407        }
408    }
409
410    /// Best-effort undo of an `install()` that failed partway through steps
411    /// 1-3. See the module docs.
412    async fn rollback_partial_install(&self, rollback: &InstallRollback) {
413        for id in &rollback.mcp_ids_added {
414            self.remove_mcp_server(id).await;
415        }
416        for id in &rollback.preset_ids_added {
417            self.remove_prompt_preset(id).await;
418        }
419        for id in &rollback.service_ids_started {
420            let _ = self.state.service_manager.stop_service(id).await;
421        }
422    }
423
424    /// Upsert one provenance row into `installed.json`. Used both for the
425    /// pre-registration `Installing` journal row and the final `Installed`
426    /// commit — the ONLY two writers of `installed.json` in `install`. Both
427    /// run under [`PLUGIN_OP_LOCK`], so the load/add/save is race-free.
428    async fn upsert_provenance(&self, entry: InstalledPlugin, path: &Path) -> PluginResult<()> {
429        let mut store = InstalledPlugins::load(path).await?;
430        store.add(entry);
431        store.save(path).await?;
432        Ok(())
433    }
434
435    /// Abort an in-process `install` failure: best-effort undo of the partial
436    /// registration (steps 1-3), then restore `installed.json` to its
437    /// pre-install state — re-writing the original `previous` row on an
438    /// upgrade/recovery, or removing the id's row entirely on a fresh install
439    /// — so the `Installing` journal row we wrote up front never lingers after
440    /// a clean in-process failure. (A HARD kill is the only path that
441    /// intentionally leaves an `Installing` row, for the next op to recover.)
442    async fn abort_install(
443        &self,
444        rollback: &InstallRollback,
445        previous: &Option<InstalledPlugin>,
446        plugin_id: &str,
447        path: &Path,
448    ) {
449        self.rollback_partial_install(rollback).await;
450        let restore = match previous {
451            Some(prev) => self.upsert_provenance(prev.clone(), path).await,
452            None => match InstalledPlugins::load(path).await {
453                Ok(mut store) => {
454                    store.remove(plugin_id);
455                    store.save(path).await
456                }
457                Err(error) => Err(error),
458            },
459        };
460        if let Err(error) = restore {
461            tracing::warn!(
462                %plugin_id,
463                %error,
464                "failed to restore provenance after aborting a failed install; a stale \
465                 `installing` row may remain (recoverable by a retry)"
466            );
467        }
468    }
469
470    /// Step 1: MCP. Returns the ids actually (re-)registered
471    /// (`reconciliation.to_register`), which — once past the conflict gate —
472    /// is exactly the declared id set.
473    async fn register_mcp(
474        &self,
475        manifest: &PluginManifest,
476        resolved_mcp_servers: Vec<McpServerConfig>,
477        previously_owned: &[String],
478        rollback: &mut InstallRollback,
479    ) -> PluginResult<Vec<String>> {
480        if resolved_mcp_servers.is_empty() {
481            return Ok(Vec::new());
482        }
483
484        let declared_ids: Vec<String> = manifest
485            .provides
486            .mcp_servers
487            .iter()
488            .map(|entry| entry.id.clone())
489            .collect();
490        let existing_ids: Vec<String> = {
491            let config = self.state.config.read().await;
492            config.mcp.servers.iter().map(|s| s.id.clone()).collect()
493        };
494
495        let reconciliation = reconcile_exclusive(&declared_ids, &existing_ids, previously_owned);
496        if !reconciliation.foreign_conflicts.is_empty() {
497            return Err(PluginError::Conflict {
498                kind: "mcp server",
499                name: reconciliation.foreign_conflicts.join(", "),
500                plugin_id: manifest.id.clone(),
501            });
502        }
503
504        let to_register: HashSet<&str> = reconciliation
505            .to_register
506            .iter()
507            .map(String::as_str)
508            .collect();
509        let configs_to_register: Vec<McpServerConfig> = resolved_mcp_servers
510            .into_iter()
511            .filter(|config| to_register.contains(config.id.as_str()))
512            .collect();
513
514        let owned_configs = configs_to_register.clone();
515        let declared_for_recheck = declared_ids.clone();
516        let owned_for_recheck: Vec<String> = previously_owned.to_vec();
517        let plugin_id_for_recheck = manifest.id.clone();
518        let forced_mcp_replacements = reconciliation.to_register.iter().cloned().collect();
519        self.state
520            .update_config_with_forced_mcp_replacements(
521                move |config| {
522                    // TOCTOU guard: re-run the ownership pre-check against the
523                    // LIVE config while holding config_io_lock, so a foreign
524                    // entry that landed between our earlier read and now can't
525                    // be silently clobbered (and then recorded as
526                    // plugin-owned, re-opening BLOCKER-1 under a race).
527                    // Concurrent PLUGIN ops are already excluded by
528                    // PLUGIN_OP_LOCK; this closes the residual window against a
529                    // concurrent NON-plugin config write.
530                    let live_existing: Vec<String> =
531                        config.mcp.servers.iter().map(|s| s.id.clone()).collect();
532                    let live = reconcile_exclusive(
533                        &declared_for_recheck,
534                        &live_existing,
535                        &owned_for_recheck,
536                    );
537                    if !live.foreign_conflicts.is_empty() {
538                        return Err(AppError::BadRequest(format!(
539                            "mcp server(s) '{}' now conflict with a non-plugin entry (a concurrent \
540                             change landed mid-install); refusing to overwrite for plugin '{}'",
541                            live.foreign_conflicts.join(", "),
542                            plugin_id_for_recheck
543                        )));
544                    }
545                    // Shared by-id merge (same helper import_servers uses).
546                    for server in &owned_configs {
547                        upsert_server_by_id(&mut config.mcp.servers, server.clone());
548                    }
549                    Ok(())
550                },
551                ConfigUpdateEffects {
552                    reload_provider: bamboo_config::patch::ReloadMode::None,
553                    reconcile_mcp: bamboo_config::patch::ReloadMode::BestEffort,
554                },
555                forced_mcp_replacements,
556            )
557            .await
558            .map_err(|error| {
559                PluginError::Registration(format!("failed to write mcp servers to config: {error}"))
560            })?;
561        // The config generation is committed and live before ownership is
562        // claimed. Runtime activation is generation-serialized and reports
563        // degraded MCP health on failure, preserving the installer's existing
564        // best-effort activation contract without an out-of-lock start.
565        rollback.mcp_ids_added = reconciliation.to_register.clone();
566
567        Ok(reconciliation.to_register)
568    }
569
570    /// Step 1b: Services (issue #479, prereq for epic #477). Same
571    /// REFUSE-on-conflict + best-effort-start shape as [`Self::register_mcp`],
572    /// against [`Self::existing_service_ids`] instead of `config.json` (there
573    /// is no shared config document for services — provenance itself is the
574    /// ownership store, see that method's doc comment).
575    async fn register_services(
576        &self,
577        manifest: &PluginManifest,
578        plugin_dir: &Path,
579        previously_owned: &[String],
580        rollback: &mut InstallRollback,
581    ) -> PluginResult<Vec<String>> {
582        if manifest.provides.services.is_empty() {
583            return Ok(Vec::new());
584        }
585
586        let declared_ids: Vec<String> = manifest
587            .provides
588            .services
589            .iter()
590            .map(|entry| entry.id.clone())
591            .collect();
592        let existing_ids = self.existing_service_ids(&manifest.id).await?;
593        let reconciliation = reconcile_exclusive(&declared_ids, &existing_ids, previously_owned);
594        if !reconciliation.foreign_conflicts.is_empty() {
595            return Err(PluginError::Conflict {
596                kind: "service",
597                name: reconciliation.foreign_conflicts.join(", "),
598                plugin_id: manifest.id.clone(),
599            });
600        }
601
602        // Ownership is claimed regardless of individual start outcomes below
603        // (matches `register_mcp`'s "config write succeeded, so record
604        // ownership" contract — here there is no config write, so this is
605        // simply claimed up front).
606        rollback.service_ids_added = reconciliation.to_register.clone();
607
608        self.ensure_service_config_parent_dir(&manifest.id).await?;
609        let platform = Platform::current().unwrap_or(Platform::Linux);
610        let to_register: HashSet<&str> = reconciliation
611            .to_register
612            .iter()
613            .map(String::as_str)
614            .collect();
615
616        for entry in &manifest.provides.services {
617            if !to_register.contains(entry.id.as_str()) {
618                continue;
619            }
620            // Stop any stale running instance first — covers a leftover
621            // from a crashed install/upgrade recovery (a genuine same-id
622            // upgrade already had its old service stopped BEFORE staging by
623            // `stop_services_for_upgrade`, see the module docs' "Same-id
624            // upgrade ordering").
625            let _ = self.state.service_manager.stop_service(&entry.id).await;
626            if !entry.enabled {
627                continue;
628            }
629            let config = self.resolve_service_config(&manifest.id, entry, plugin_dir, platform);
630            match self.state.service_manager.start_service(config).await {
631                Ok(()) => rollback.service_ids_started.push(entry.id.clone()),
632                Err(error) => tracing::warn!(
633                    service_id = %entry.id,
634                    %error,
635                    "plugin-registered service failed to start; ownership kept (best-effort, matches mcp)"
636                ),
637            }
638        }
639
640        Ok(reconciliation.to_register)
641    }
642
643    /// Step 2: Prompts. Rename-on-collision (never refuses) — returns the
644    /// ACTUAL ids used (after any rename), which is what provenance must
645    /// record.
646    async fn register_prompts(&self, manifest: &PluginManifest) -> PluginResult<Vec<String>> {
647        if manifest.provides.prompts.is_empty() {
648            return Ok(Vec::new());
649        }
650
651        let path = self.prompt_presets_path();
652        let mut store = load_store(&path).await.map_err(|error| {
653            PluginError::Registration(format!("failed to load prompt-presets.json: {error}"))
654        })?;
655
656        let mut existing_ids: HashSet<String> = store
657            .prompts
658            .iter()
659            .map(|preset| preset.id.clone())
660            .collect();
661        // `general_assistant` (bamboo-server's DEFAULT_PRESET_ID) is never a
662        // row in the store, so it wouldn't otherwise appear in `existing_ids`
663        // — but manifest validation already rejects any plugin declaring it
664        // (RESERVED_PRESET_IDS), so no extra guard is needed here.
665
666        let mut actual_ids = Vec::with_capacity(manifest.provides.prompts.len());
667        for preset in &manifest.provides.prompts {
668            let actual_id = ensure_unique_preset_id(&preset.id, &existing_ids);
669            store.prompts.push(StoredPromptPreset {
670                id: actual_id.clone(),
671                name: preset.name.clone(),
672                description: preset.description.clone(),
673                content: preset.content.clone(),
674            });
675            existing_ids.insert(actual_id.clone());
676            actual_ids.push(actual_id);
677        }
678
679        save_store(&path, &store).await.map_err(|error| {
680            PluginError::Registration(format!("failed to persist prompt-presets.json: {error}"))
681        })?;
682
683        Ok(actual_ids)
684    }
685
686    /// Step 3: validate legacy plugin workflows for in-place discovery.
687    ///
688    /// Workflow markdown stays under `<plugin_dir>/workflows`; the SkillStore
689    /// discovers it as a read-only legacy adapter. Nothing is copied into the
690    /// shared global workflows directory, so a plugin filename can never
691    /// conflict with or overwrite a user's own legacy workflow.
692    async fn validate_workflows_in_place(
693        &self,
694        manifest: &PluginManifest,
695        plugin_dir: &Path,
696    ) -> PluginResult<()> {
697        if manifest.provides.workflows.is_empty() {
698            return Ok(());
699        }
700
701        let workflows_dir = plugin_dir.join("workflows");
702        let directory_metadata = fs::symlink_metadata(&workflows_dir).await?;
703        if !directory_metadata.is_dir() || directory_metadata.file_type().is_symlink() {
704            return Err(PluginError::InvalidManifest(
705                "plugin workflows must live in a real workflows directory".to_string(),
706            ));
707        }
708        let declared: HashSet<&str> = manifest
709            .provides
710            .workflows
711            .iter()
712            .map(String::as_str)
713            .collect();
714        let mut actual = HashSet::new();
715        let mut entries = fs::read_dir(&workflows_dir).await?;
716        while let Some(entry) = entries.next_entry().await? {
717            let path = entry.path();
718            if path.extension().and_then(|value| value.to_str()) != Some("md") {
719                continue;
720            }
721            let file_type = entry.file_type().await?;
722            let Some(filename) = entry.file_name().to_str().map(str::to_string) else {
723                return Err(PluginError::InvalidManifest(
724                    "plugin workflow filename must be UTF-8".to_string(),
725                ));
726            };
727            if !file_type.is_file() || file_type.is_symlink() {
728                return Err(PluginError::InvalidManifest(format!(
729                    "workflow '{filename}' must be a regular in-place markdown file"
730                )));
731            }
732            actual.insert(filename);
733        }
734        if actual.len() != declared.len()
735            || !actual.iter().all(|name| declared.contains(name.as_str()))
736        {
737            return Err(PluginError::InvalidManifest(
738                "provides.workflows must declare every workflows/*.md file exactly once"
739                    .to_string(),
740            ));
741        }
742
743        for filename in &manifest.provides.workflows {
744            let stem = filename.strip_suffix(".md").unwrap_or(filename);
745            if !bamboo_config::paths::is_safe_workflow_name(stem) {
746                return Err(PluginError::InvalidManifest(format!(
747                    "workflow filename '{filename}' is not a safe workflow name"
748                )));
749            }
750            let source_path = workflows_dir.join(filename);
751            let metadata = fs::symlink_metadata(&source_path).await?;
752            if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
753                return Err(PluginError::InvalidManifest(format!(
754                    "workflow '{filename}' must be a regular in-place markdown file"
755                )));
756            }
757        }
758        Ok(())
759    }
760
761    /// **Same-id upgrade ordering** (issue #479 "Install-flow deltas" /
762    /// "Same-id upgrade ordering bug risk"): `plugin_source::stage_plugin_source`
763    /// swaps `plugin_dir`'s ENTIRE contents (old bundle moved to a
764    /// `.backup-*` dir, staged bundle renamed into place) BEFORE
765    /// `install()` — and therefore [`Self::register_services`] — ever runs.
766    /// A still-running old service process holding the old binary open
767    /// during that swap is at best running stale code post-swap and at
768    /// worst (Windows) blocks the rename outright. The minimal seam that
769    /// fixes the ordering without restructuring `stage_plugin_source`/
770    /// `install()`: the HTTP `update_plugin` handler calls this BEFORE
771    /// `stage_plugin_source`, using the URL path's target id (known up
772    /// front for an upgrade, unlike a fresh `install`) to look up the
773    /// CURRENTLY-installed row's `registered.service_ids` and stop each one.
774    /// Net effect: stop (old binary) → swap (new binary) → start (new
775    /// binary, via `register_services` inside `install()`), exactly the
776    /// sequencing the issue calls for.
777    ///
778    /// Best-effort: returns exactly the ids that were actually running and
779    /// got stopped (not e.g. an already-stopped or unknown id), so a
780    /// subsequently-failed upgrade can restart precisely those — see
781    /// [`Self::restart_services_after_failed_upgrade`]. A plugin with no
782    /// prior install (or no services) returns an empty vec and stops
783    /// nothing.
784    pub async fn stop_services_for_upgrade(&self, plugin_id: &str) -> Vec<String> {
785        let store = match InstalledPlugins::load(&self.installed_json_path()).await {
786            Ok(store) => store,
787            Err(error) => {
788                tracing::warn!(
789                    %plugin_id,
790                    %error,
791                    "stop_services_for_upgrade: failed to load installed.json; skipping"
792                );
793                return Vec::new();
794            }
795        };
796        let Some(entry) = store.get(plugin_id) else {
797            return Vec::new();
798        };
799        let mut stopped = Vec::with_capacity(entry.registered.service_ids.len());
800        for service_id in &entry.registered.service_ids {
801            match self.state.service_manager.stop_service(service_id).await {
802                Ok(()) => stopped.push(service_id.clone()),
803                Err(error) => tracing::debug!(
804                    service_id = %service_id,
805                    %error,
806                    "stop_services_for_upgrade: service was not running; nothing to stop"
807                ),
808            }
809        }
810        stopped
811    }
812
813    /// Counterpart to [`Self::stop_services_for_upgrade`]: called after a
814    /// FAILED upgrade whose `StagedPlugin::rollback()` already restored
815    /// `plugin_dir` to the pre-upgrade bundle's bytes — re-reads that
816    /// (now-restored) OLD `plugin.json` and restarts exactly the services in
817    /// `stopped` that it still declares as `enabled`. Best-effort/
818    /// log-and-continue: a failure here leaves the affected service stopped
819    /// (a degraded-but-safe outcome — never silently double-runs an old and
820    /// a new instance) rather than panicking the request.
821    pub async fn restart_services_after_failed_upgrade(&self, plugin_id: &str, stopped: &[String]) {
822        if stopped.is_empty() {
823            return;
824        }
825        let store = match InstalledPlugins::load(&self.installed_json_path()).await {
826            Ok(store) => store,
827            Err(error) => {
828                tracing::warn!(
829                    %plugin_id,
830                    %error,
831                    "restart_services_after_failed_upgrade: failed to load installed.json"
832                );
833                return;
834            }
835        };
836        let Some(entry) = store.get(plugin_id) else {
837            return;
838        };
839        let manifest_path = entry.plugin_dir.join("plugin.json");
840        let manifest = match fs::read_to_string(&manifest_path)
841            .await
842            .ok()
843            .and_then(|raw| PluginManifest::parse_str(&raw).ok())
844        {
845            Some(manifest) => manifest,
846            None => {
847                tracing::warn!(
848                    %plugin_id,
849                    path = %manifest_path.display(),
850                    "restart_services_after_failed_upgrade: failed to read/parse the \
851                     rolled-back plugin.json; affected service(s) remain stopped"
852                );
853                return;
854            }
855        };
856        let platform = Platform::current().unwrap_or(Platform::Linux);
857        for svc in &manifest.provides.services {
858            if !stopped.contains(&svc.id) || !svc.enabled {
859                continue;
860            }
861            let config = self.resolve_service_config(plugin_id, svc, &entry.plugin_dir, platform);
862            if let Err(error) = self.state.service_manager.start_service(config).await {
863                tracing::warn!(
864                    service_id = %svc.id,
865                    %plugin_id,
866                    %error,
867                    "failed to restart service after a failed upgrade rolled back to the \
868                     previous plugin bundle; service remains stopped"
869                );
870            }
871        }
872    }
873}
874
875#[async_trait]
876impl PluginInstaller for ServerPluginInstaller {
877    async fn install(
878        &self,
879        manifest: &PluginManifest,
880        plugin_dir: &Path,
881        source: PluginSource,
882        disposition: InstallDisposition,
883        installed_at: DateTime<Utc>,
884    ) -> PluginResult<InstalledPlugin> {
885        // Serialize the whole op against every other plugin install/uninstall
886        // (process-wide) — held across all steps AND rollback. See module docs
887        // "Concurrency".
888        let _op_guard = PLUGIN_OP_LOCK.lock().await;
889
890        let installed_json_path = self.installed_json_path();
891
892        // Disposition gate (AlreadyInstalled only for a COMPLETED prior
893        // install; an `Installing` leftover is returned for recovery) + the
894        // rest of the pure, AppState-free validation this crate can already
895        // do (manifest shape, platform gate, on-disk skill/workflow
896        // existence, `provides.skills` authoritativeness).
897        let previous =
898            load_previous_for_disposition(&installed_json_path, &manifest.id, disposition).await?;
899        let resolved_mcp_servers = preflight_install(manifest, plugin_dir).await?;
900
901        // The set this install INTENDS to own, by declaration order. Used both
902        // for the crash-safety journal row (below) and the step-0 drop-diff.
903        let intended = RegisteredCapabilities {
904            mcp_server_ids: manifest
905                .provides
906                .mcp_servers
907                .iter()
908                .map(|entry| entry.id.clone())
909                .collect(),
910            skill_dirs: manifest.provides.skills.clone(),
911            preset_ids: manifest
912                .provides
913                .prompts
914                .iter()
915                .map(|preset| preset.id.clone())
916                .collect(),
917            // New workflow publications are discovered in place. Keeping this
918            // legacy copied-file field empty also makes an upgrade clean up
919            // files copied by pre-#561 installs through the drop-diff below.
920            workflow_filenames: Vec::new(),
921            service_ids: manifest
922                .provides
923                .services
924                .iter()
925                .map(|entry| entry.id.clone())
926                .collect(),
927        };
928
929        // Step 0: upgrade drop-diff. Computed from the NEW manifest's plain
930        // declared ids (see module docs re: the preset-rename caveat) vs the
931        // OLD install's registered set — de-register whatever the new version
932        // no longer declares BEFORE registering anything new (BLOCKER 2). Also
933        // fires for a recovery over an `Installing` leftover: its intended set
934        // is diffed the same way, so a crashed attempt's extra ids get cleaned.
935        if let Some(previous) = &previous {
936            let dropped = intended.removed_since(&previous.registered);
937            if !dropped.is_empty() {
938                tracing::info!(
939                    plugin_id = %manifest.id,
940                    recovering = previous.status == PluginInstallStatus::Installing,
941                    dropped_mcp = ?dropped.mcp_server_ids,
942                    dropped_presets = ?dropped.preset_ids,
943                    dropped_workflows = ?dropped.workflow_filenames,
944                    dropped_services = ?dropped.service_ids,
945                    "install drop-diff: de-registering capabilities the new/completed version no longer declares"
946                );
947                self.deregister_capabilities(&dropped).await;
948            }
949        }
950
951        let previously_owned_mcp = previous
952            .as_ref()
953            .map(|p| p.registered.mcp_server_ids.clone())
954            .unwrap_or_default();
955        let previously_owned_services = previous
956            .as_ref()
957            .map(|p| p.registered.service_ids.clone())
958            .unwrap_or_default();
959
960        // Crash-safety journal: write an `Installing` provenance row recording
961        // the INTENDED ownership set BEFORE mutating any shared store, so a
962        // hard kill mid-install leaves a recoverable marker (see module docs
963        // "Crash safety"). On a fresh install this creates the row; on an
964        // upgrade/recovery it overwrites the prior row.
965        self.upsert_provenance(
966            InstalledPlugin {
967                id: manifest.id.clone(),
968                version: manifest.version.clone(),
969                source: source.clone(),
970                plugin_dir: plugin_dir.to_path_buf(),
971                installed_at,
972                status: PluginInstallStatus::Installing,
973                registered: intended.clone(),
974            },
975            &installed_json_path,
976        )
977        .await?;
978
979        let mut rollback = InstallRollback::default();
980
981        // Step 1: MCP.
982        let mcp_server_ids = match self
983            .register_mcp(
984                manifest,
985                resolved_mcp_servers,
986                &previously_owned_mcp,
987                &mut rollback,
988            )
989            .await
990        {
991            Ok(ids) => ids,
992            Err(error) => {
993                self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
994                    .await;
995                return Err(error);
996            }
997        };
998
999        // Step 1b: Services (issue #479). Runs right after MCP, before the
1000        // never-refusing Prompts step, so a services conflict fails the
1001        // install as early as the other REFUSE-on-conflict kinds do. Note:
1002        // for a same-id UPGRADE, the OLD service (if any) was already
1003        // stopped by `stop_services_for_upgrade` before `stage_plugin_source`
1004        // swapped `plugin_dir` — see that method's doc comment on the
1005        // stop→swap→start sequencing.
1006        let service_ids = match self
1007            .register_services(
1008                manifest,
1009                plugin_dir,
1010                &previously_owned_services,
1011                &mut rollback,
1012            )
1013            .await
1014        {
1015            Ok(ids) => ids,
1016            Err(error) => {
1017                self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1018                    .await;
1019                return Err(error);
1020            }
1021        };
1022
1023        // Step 2: Prompts.
1024        let preset_ids = match self.register_prompts(manifest).await {
1025            Ok(ids) => {
1026                rollback.preset_ids_added = ids.clone();
1027                ids
1028            }
1029            Err(error) => {
1030                self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1031                    .await;
1032                return Err(error);
1033            }
1034        };
1035
1036        // Step 3: Workflows remain inside the plugin bundle and are discovered
1037        // by the shared SkillStore as read-only legacy adapters.
1038        let workflow_filenames = match self.validate_workflows_in_place(manifest, plugin_dir).await
1039        {
1040            Ok(()) => Vec::new(),
1041            Err(error) => {
1042                self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1043                    .await;
1044                return Err(error);
1045            }
1046        };
1047
1048        // Step 4: Skills — nothing to register, just record the
1049        // declared+validated dir names (preflight_install already confirmed
1050        // every declared dir exists and that no undeclared dir is present).
1051        let skill_dirs = manifest.provides.skills.clone();
1052
1053        // Step 5: commit provenance — flip the journal row to `Installed` with
1054        // the ACTUAL registered set (renamed preset ids, the to_register mcp/
1055        // workflow subsets). Only reached once 0-4 all succeeded.
1056        let registered = RegisteredCapabilities {
1057            mcp_server_ids,
1058            skill_dirs,
1059            preset_ids,
1060            workflow_filenames,
1061            service_ids,
1062        };
1063        let entry = InstalledPlugin {
1064            id: manifest.id.clone(),
1065            version: manifest.version.clone(),
1066            source,
1067            plugin_dir: plugin_dir.to_path_buf(),
1068            installed_at,
1069            status: PluginInstallStatus::Installed,
1070            registered,
1071        };
1072        self.upsert_provenance(entry.clone(), &installed_json_path)
1073            .await?;
1074
1075        Ok(entry)
1076    }
1077
1078    async fn uninstall(&self, id: &str) -> PluginResult<()> {
1079        // Serialize against every other plugin op (see module docs).
1080        let _op_guard = PLUGIN_OP_LOCK.lock().await;
1081
1082        let installed_json_path = self.installed_json_path();
1083        let mut store = InstalledPlugins::load(&installed_json_path).await?;
1084        // Works on an `Installing` (crash-leftover) row too, so a crashed
1085        // install is never un-uninstallable.
1086        let Some(entry) = store.get(id).cloned() else {
1087            return Err(PluginError::NotFound(id.to_string()));
1088        };
1089
1090        // De-register everything this plugin's `registered` set names — by
1091        // construction (see bamboo-plugin's ownership contract) this can
1092        // only ever be entries the plugin itself created. Idempotent: a
1093        // manually-removed entry is logged and skipped, never a hard error.
1094        self.deregister_capabilities(&entry.registered).await;
1095
1096        // Remove the plugin's own files BEFORE clearing provenance: if this
1097        // fails (e.g. a permission error), provenance is left intact so a
1098        // retry is safe (the de-registration above is idempotent, so
1099        // re-running it is a harmless no-op) rather than leaving an
1100        // unregistered-but-still-on-disk `skills/` dir that discovery would
1101        // keep picking up despite `uninstall` having "succeeded".
1102        match fs::remove_dir_all(&entry.plugin_dir).await {
1103            Ok(()) => {}
1104            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1105            Err(error) => return Err(PluginError::Io(error)),
1106        }
1107
1108        store.remove(id);
1109        store.save(&installed_json_path).await?;
1110        Ok(())
1111    }
1112
1113    async fn list(&self) -> PluginResult<Vec<InstalledPlugin>> {
1114        let store = InstalledPlugins::load(&self.installed_json_path()).await?;
1115        Ok(store.plugins)
1116    }
1117}
1118
1119// ---------------------------------------------------------------------
1120// Free helpers shared between `ServerPluginInstaller` (instance methods
1121// above, which delegate here) and `boot_reconcile_services` (which has no
1122// `ServerPluginInstaller`/`AppState` handle to call instance methods on —
1123// see `app_state::builder`, which calls it before `AppState` finishes
1124// constructing).
1125// ---------------------------------------------------------------------
1126
1127/// See `ServerPluginInstaller::service_config_path`'s doc comment for the
1128/// full rationale (kept there since that's the reader's first encounter).
1129fn service_config_path_under(app_data_dir: &Path, plugin_id: &str) -> PathBuf {
1130    app_data_dir
1131        .join("plugin_service_config")
1132        .join(plugin_id)
1133        .join("config.json")
1134}
1135
1136fn resolve_service_config_under(
1137    app_data_dir: &Path,
1138    plugin_id: &str,
1139    entry: &ServiceManifestEntry,
1140    plugin_dir: &Path,
1141    platform: Platform,
1142) -> ServiceRuntimeConfig {
1143    let resolved = entry.resolve(plugin_dir, plugin_id, platform);
1144    ServiceRuntimeConfig {
1145        id: resolved.id,
1146        plugin_id: plugin_id.to_string(),
1147        name: resolved.name,
1148        command: resolved.command,
1149        args: resolved.args,
1150        cwd: resolved.cwd,
1151        env: resolved.env,
1152        health_check: resolved.health_check,
1153        restart_policy: resolved.restart_policy,
1154        graceful_shutdown: resolved.graceful_shutdown,
1155        user_config_path: service_config_path_under(app_data_dir, plugin_id),
1156    }
1157}
1158
1159/// Boot-time reconcile (issue #479): start every ENABLED, plugin-owned
1160/// service that `installed.json` says should be running but has no live
1161/// [`ServiceManager`] runtime — the previous `bamboo serve` process (if any)
1162/// died along with every service it supervised (child processes are spawned
1163/// `kill_on_drop`, and nothing about a running service persists
1164/// cross-process). Called from `app_state::builder` the same way
1165/// `app_state::init::init_mcp_manager` kicks off its background MCP
1166/// bootstrap — the caller is expected to `tokio::spawn` this, NOT await it
1167/// inline, so server startup is never blocked on plugin service spawns.
1168///
1169/// Deliberately reads `installed.json` + each plugin's on-disk
1170/// `plugin.json` directly rather than going through `ServerPluginInstaller`
1171/// (which needs a fully-built `web::Data<AppState>` this runs before).
1172pub async fn boot_reconcile_services(app_data_dir: &Path, service_manager: &ServiceManager) {
1173    let installed_json_path = app_data_dir.join("plugins").join("installed.json");
1174    let store = match InstalledPlugins::load(&installed_json_path).await {
1175        Ok(store) => store,
1176        Err(error) => {
1177            tracing::warn!(
1178                %error,
1179                "service boot-reconcile: failed to load installed.json; skipping"
1180            );
1181            return;
1182        }
1183    };
1184
1185    let platform = Platform::current().unwrap_or(Platform::Linux);
1186    for plugin in store.list() {
1187        if plugin.registered.service_ids.is_empty() {
1188            continue;
1189        }
1190        let manifest_path = plugin.plugin_dir.join("plugin.json");
1191        let manifest = match fs::read_to_string(&manifest_path)
1192            .await
1193            .ok()
1194            .and_then(|raw| PluginManifest::parse_str(&raw).ok())
1195        {
1196            Some(manifest) => manifest,
1197            None => {
1198                tracing::warn!(
1199                    plugin_id = %plugin.id,
1200                    path = %manifest_path.display(),
1201                    "service boot-reconcile: failed to read/parse plugin.json; skipping this \
1202                     plugin's services"
1203                );
1204                continue;
1205            }
1206        };
1207
1208        let owned: HashSet<&str> = plugin
1209            .registered
1210            .service_ids
1211            .iter()
1212            .map(String::as_str)
1213            .collect();
1214        for entry in &manifest.provides.services {
1215            if !entry.enabled || !owned.contains(entry.id.as_str()) {
1216                continue;
1217            }
1218            if service_manager.is_running(&entry.id) {
1219                continue;
1220            }
1221            let config = resolve_service_config_under(
1222                app_data_dir,
1223                &plugin.id,
1224                entry,
1225                &plugin.plugin_dir,
1226                platform,
1227            );
1228            if let Some(parent) = config.user_config_path.parent() {
1229                if let Err(error) = fs::create_dir_all(parent).await {
1230                    tracing::warn!(
1231                        service_id = %entry.id,
1232                        plugin_id = %plugin.id,
1233                        %error,
1234                        "service boot-reconcile: failed to create service config parent dir"
1235                    );
1236                }
1237            }
1238            match service_manager.start_service(config).await {
1239                Ok(()) => tracing::info!(
1240                    service_id = %entry.id,
1241                    plugin_id = %plugin.id,
1242                    "service boot-reconcile: started"
1243                ),
1244                Err(error) => tracing::warn!(
1245                    service_id = %entry.id,
1246                    plugin_id = %plugin.id,
1247                    %error,
1248                    "service boot-reconcile: failed to start"
1249                ),
1250            }
1251        }
1252    }
1253}
1254
1255#[cfg(test)]
1256mod tests;