Skip to main content

Module plugin_installer

Module plugin_installer 

Source
Expand description

ServerPluginInstaller — the AppState-backed implementation of bamboo_plugin::PluginInstaller (Wave 2 § Installer-core agent, PLUGIN_PLAN.md).

bamboo-plugin is an infra-layer crate with no access to AppState, so its LocalPluginInstaller reference skeleton stops at PluginError::NotImplemented exactly where capability registration needs config.json, mcp_manager, prompt-presets.json, and workflows_dir(). This type is the real implementation: an ordinary downstream impl PluginInstaller for ServerPluginInstaller (the trait is foreign, the type is local — no orphan-rule issue).

§Why a borrowed web::Data<AppState> and no AppState struct change

ServerPluginInstaller holds a web::Data<AppState> clone — the exact handle every HTTP handler in this crate already receives as an argument (web::Data is Arc-backed, so cloning it is cheap). An HTTP handler constructs one per request: ServerPluginInstaller::new(state.clone()). AppState itself is intentionally untouched — no new field, no coordinated append to app_state/mod.rs / app_state/builder.rs — so this branch can never conflict with the other Wave-2 branches that also stack on feat/plugin-framework.

§Path derivation: state.app_data_dir, not the bamboo_config::paths globals

bamboo_config::paths::{plugins_dir, workflows_dir, plugins_installed_json_path, ...} all resolve through a process-wide OnceLock that AppState::new seeds ONCE per process (first caller wins — see its doc comment). That is correct for the single production AppState per process, but this crate’s own test suite already builds many AppStates over different tempfile::tempdir()s in the same test binary (e.g. app_state::tests::test_app_state_creation and friends) — if this type read the global helpers, every one of those AppStates would silently share whichever tempdir happened to construct the first one. Every path below is instead derived from the borrowed state.app_data_dir field directly, exactly the pattern handlers::settings::workflows and prompt_presets::storage::store_file_path already use. In production, where there is exactly one AppState, this resolves to the identical path the global helpers would have produced.

§Concurrency

Every install/uninstall runs under a single process-wide async lock ([PLUGIN_OP_LOCK]), held for the ENTIRE operation including rollback, so the reconcile→mutate→provenance sequence is atomic w.r.t. any other plugin op. This closes three concurrency gaps at once: the installed.json and prompt-presets.json load/modify/save lost-update races, and the MCP reconcile→config-write TOCTOU. As additional defense against a concurrent NON-plugin config write (which does not take this lock), the MCP step also RE-runs its ownership pre-check INSIDE the update_config closure, under config_io_lock, and aborts rather than clobbering if a foreign entry appeared. Lock ordering is PLUGIN_OP_LOCKconfig_io_lock (never the reverse) — see [PLUGIN_OP_LOCK].

Narrower, accepted gap: ServerPluginInstaller::register_workflows does NOT get the equivalent live re-check that MCP’s step does — there is no workflows_io_lock analogous to config_io_lock to re-run reconcile_exclusive inside, because workflow files are plain files under workflows_dir(), not a single locked/rewritten document like config.json. So a concurrent NON-plugin write of a same-named workflow file landing in the window between existing_workflow_filenames() and the actual fs::write below could still be clobbered (and then recorded as plugin-owned, which a later uninstall would incorrectly delete). Deferred: this is the same class of race the MCP re-check closes, just for a store that has no equivalent lock to hook into yet.

§Crash safety (process killed mid-install)

In-process rollback (below) only fires on an Err. A HARD kill after the MCP step wrote to config.json but before provenance is committed would, without a journal, leave: reconcile_exclusive seeing the orphaned mcp id as existing-but-not-owned → a false Conflict on the retry, AND uninstall returning NotFound (no provenance) → the user stuck hand-editing config.json. To prevent that, install writes a provenance row with status PluginInstallStatus::Installing — recording the INTENDED ownership set — BEFORE steps 1-4, and flips it to PluginInstallStatus::Installed only after step 5 succeeds. On the next install/upgrade of an id whose row is still Installing (a prior crash), load_previous_for_disposition returns it as previous (it does NOT trip AlreadyInstalled), so its intended set is treated as this-plugin-owned — the leftover reads as an OwnedReinstall, not a foreign conflict — and is cleaned up as an upgrade-over-incomplete. uninstall works on an Installing row too.

§Atomicity / rollback semantics

install() follows PLUGIN_PLAN.md’s numbered sequence exactly:

  1. Upgrade drop-diff (only when upgrading an already-installed id): de-register whatever the new manifest no longer declares, computed via bamboo_plugin::registry::RegisteredCapabilities::removed_since, BEFORE registering anything new. De-registration is idempotent/ best-effort (see ServerPluginInstaller::deregister_capabilities) — an entry a user already removed by hand never blocks an upgrade.
  2. MCP — ownership-checked (REFUSE on a foreign conflict, via bamboo_plugin::registry::reconcile_exclusive), merged into config.json, started.
  3. Prompts — rename-on-collision (never refuse), appended to prompt-presets.json.
  4. Workflows — ownership-checked exactly like MCP, copied into workflows_dir().
  5. Skills — nothing to register (discovered in place); just recorded.
  6. Provenance commitinstalled.json is only ever upserted after steps 0-4 all succeed.

Steps 1-3 are real, sequential mutations (config write, then file writes) — NOT a dry-run computed up front — because PLUGIN_PLAN.md requires the ownership pre-checks to run in that exact order against the LIVE state each step leaves behind. That means a HARD failure at step 2 or 3 (e.g. an MCP conflict is already past, but the workflow conflict check at step 3 fails) can happen after step 1 already wrote real entries into config.json. ServerPluginInstaller::install tracks every already-applied mutation in an [InstallRollback] and, on any hard failure from steps 1-3, best-effort UNDOES them (removes the mcp entries it just added and stops any it started, removes the presets it just appended, deletes the workflow files it just copied) before returning the error — so a caller’s retry starts from a clean slate. Provenance is never written on a failed path (step 5 is the only place installed.json is touched on success), which is the minimum safety bar even if a rollback step itself only partially succeeds (rollback operations are themselves idempotent/log-and-continue, so a second rollback attempt via a plain retry can never fail louder than the first).

One known, accepted gap: stage_plugin_source/install_plugin_from_source in crate::plugin_source additionally guard the ON-DISK plugin_dir swap itself (an upgrade’s new bundle replaces the old one’s files at a fixed path) by moving the previous bundle aside instead of deleting it, and restoring it if install() subsequently fails — see that module’s docs.

Prompt-preset drop-diff caveat: the upgrade drop-diff compares the NEW manifest’s nominal preset ids against the OLD install’s ACTUAL (possibly renamed-on-collision) registered ids. A preset that got renamed at its original install time and is still declared under its original nominal id in the new manifest will look “dropped” (the nominal id is absent from the actual old set) and get re-appended (possibly renamed again). This is harmless — preset content is just refreshed under a fresh id — and not worth a stable-id-mapping schema change for what RegisteredCapabilities already documents as the one rename (not refuse) exception.

Structs§

ServerPluginInstaller
AppState-backed PluginInstaller. See the module docs for the full design rationale (borrowing, path derivation, atomicity).