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_LOCK → config_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:
- 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 (seeServerPluginInstaller::deregister_capabilities) — an entry a user already removed by hand never blocks an upgrade. - MCP — ownership-checked (REFUSE on a foreign conflict, via
bamboo_plugin::registry::reconcile_exclusive), merged intoconfig.json, started. - Prompts — rename-on-collision (never refuse), appended to
prompt-presets.json. - Workflows — ownership-checked exactly like MCP, copied into
workflows_dir(). - Skills — nothing to register (discovered in place); just recorded.
- Provenance commit —
installed.jsonis 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§
- Server
Plugin Installer - AppState-backed
PluginInstaller. See the module docs for the full design rationale (borrowing, path derivation, atomicity).