Merge pull request #1423 from Runfusion/gsxdsm/compound-plugin

Fix Compound Engineering plugin install failing from Settings
This commit is contained in:
gsxdsm
2026-06-04 15:47:26 -07:00
committed by GitHub
6 changed files with 165 additions and 0 deletions

View File

@@ -6,3 +6,4 @@ Fix the workflow graph editor opening invisibly and bundle the Compound Engineer
- The "Graph editor" button now actually shows the editor: its overlay was rendered without the `open` class, leaving it `display: none`, so opening it looked like the workflow steps view was just dismissed.
- `fusion-plugin-compound-engineering` and `fusion-plugin-roadmap` are now listed in the dashboard's built-in plugins, so they appear under Settings → Built-in Plugins (they were implemented and registered but missing from the list).
- Installing Compound Engineering (and CLI Printing Press) from Settings → Built-in Plugins no longer fails with "Plugin manifest not found": both ids are now in the dashboard's bundled-plugin fallback set, and the Compound Engineering plugin is staged into `dist/plugins/` so packaged installs can resolve it.

View File

@@ -134,6 +134,13 @@ The user's mid-stage feedback channel: free-text guidance attached to an answer,
### Rehydration
Re-establishing a live agent handle for a paused CE Session by replaying its recorded conversation against the model. Replay is side-effect-suppressed: it reconstructs the agent's context without re-emitting events, re-streaming Live activity, or re-writing artifacts.
## Plugins
### Bundled Plugin
A plugin that ships inside the Fusion distribution itself rather than being installed from a user-supplied path — it appears under Settings → Built-in Plugins and can be auto-installed at startup.
*Avoid:* built-in plugin (as a distinct concept; the Settings label uses "Built-in" for the same thing)
A Bundled Plugin must be registered in several independently maintained surfaces — the Settings catalog, the dashboard server's bundled-id fallback set, the CLI's startup auto-install list, and the build step that stages a loadable copy into the distribution. The surfaces do not cross-check each other: a plugin registered in some but not all appears installable yet fails to install or load, so adding one means mirroring an existing bundled plugin across every surface.
## Workflow columns & traits
*Behind the `experimentalFeatures.workflowColumns` flag. With the flag off, the legacy fixed pipeline (the closed column enum + `VALID_TRANSITIONS`) is authoritative and unchanged.*

View File

@@ -0,0 +1,79 @@
---
title: Bundled plugins must be registered in 4 independent places — they drift
date: 2026-06-04
category: integration-issues
module: plugins
problem_type: integration_issue
component: tooling
symptoms:
- "Installing a built-in plugin from Settings → Built-in Plugins fails with \"Plugin manifest not found. Looked for manifest.json in: ...\""
- "Plugin shows in the Settings UI but the install POST returns 404"
- "Packaged (npm/binary) installs report missing-bundle for a plugin that works in dev"
root_cause: incomplete_setup
resolution_type: code_fix
severity: medium
tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift]
---
# Bundled plugins must be registered in 4 independent places — they drift
## Problem
Adding a bundled (built-in) plugin to Fusion requires registration in **four independently maintained lists** with no cross-check. `fusion-plugin-compound-engineering` was added to only 2 of 4, so installing it from Settings → Built-in Plugins failed with "Plugin manifest not found" (fixed in PR #1423).
The four registration points:
1. **Dashboard UI** — `BUILTIN_PLUGINS` in `packages/dashboard/app/components/PluginManager.tsx` (makes the card appear in Settings)
2. **Dashboard server** — `BUNDLED_PLUGIN_IDS` in `packages/dashboard/src/routes.ts` (lets the install route fall back to the bundled copy when the relative `./plugins/...` path misses the server cwd)
3. **CLI startup** — `BUNDLED_PLUGIN_IDS` in `packages/cli/src/plugins/bundled-plugin-install.ts` (auto-install/upgrade of bundled plugins)
4. **Build staging** — `packages/cli/tsup.config.ts` (`bundlePluginEntry` or a copy block staging the plugin into `dist/plugins/<id>/` so packaged installs have a copy at all)
A plugin with a dashboard view additionally needs client-side view registration in `packages/dashboard/app/plugins/registerBundledPluginViews.ts`.
## Symptoms
- Settings shows the plugin card, but clicking install errors with `Plugin manifest not found. Looked for manifest.json in: <cwd>/plugins/<id>` — the cwd-relative path missed and the bundled fallback was skipped because the id wasn't in routes.ts's `BUNDLED_PLUGIN_IDS`.
- A sibling plugin added in the same commit (roadmap) installs fine — it was in all four lists.
- In packaged installs, `ensureBundledPluginInstalled` logs/returns `missing-bundle` because tsup never staged the plugin into `dist/plugins/`.
## What Didn't Work
- Assuming the UI list + CLI list were sufficient — the dashboard server keeps its **own** copy of the bundled-id set, and the install route's fallback silently returns null for unknown ids.
- The existing bundled-fallback route tests appeared to cover this, but their mocks let cwd resolution succeed (mock matched any path containing the plugin id), so the fallback branch was never actually exercised.
## Solution
Register the plugin in all four places. For the missing two:
```ts
// packages/dashboard/src/routes.ts
const BUNDLED_PLUGIN_IDS = new Set([
// ...
"fusion-plugin-cli-printing-press",
"fusion-plugin-compound-engineering",
]);
```
```ts
// packages/cli/tsup.config.ts (onSuccess)
await bundlePluginEntry({
pluginId: "fusion-plugin-compound-engineering",
srcDir: compoundEngineeringPluginSrc,
destDir: compoundEngineeringPluginDest,
});
```
## Why This Works
The Settings card sends a relative `./plugins/<id>` path. The server resolves it against `process.cwd()` — normally the user's project dir, not the Fusion repo — so it 404s and falls back to `extractBundledPluginId()`, which only recognizes ids in routes.ts's `BUNDLED_PLUGIN_IDS`. Adding the id makes the fallback resolve the staged bundled copy; the tsup staging block guarantees that copy exists in packaged installs.
## Prevention
- **When adding a bundled plugin, grep for an existing one** (e.g. `rg -l "fusion-plugin-roadmap" packages/` ) and mirror every hit — that surfaces all four lists plus view registration.
- Route tests must force the fallback: mock fs so the cwd-relative path **misses** and only `dist/plugins/<id>` exists (see "installs bundled compound engineering plugin when relative path misses cwd" in `packages/dashboard/src/__tests__/plugin-routes.test.ts`). A mock that matches any path containing the plugin id tests nothing.
- Consider a future consistency test asserting every `BUILTIN_PLUGINS` UI entry with a `path` is present in both server-side `BUNDLED_PLUGIN_IDS` sets.
## Related Issues
- PR #1423 — the fix
- Commit `ff0750cd1` — added CE/Roadmap to the UI list (2 of 4 registrations)

View File

@@ -41,6 +41,8 @@ const reportsPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-r
const reportsPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-reports");
const cliPrintingPressPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-cli-printing-press");
const cliPrintingPressPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-cli-printing-press");
const compoundEngineeringPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-compound-engineering");
const compoundEngineeringPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-compound-engineering");
const dashboardClientStub = `<!doctype html>
<html lang="en">
<head>
@@ -241,6 +243,12 @@ const cliBuildConfig = {
destDir: roadmapPluginDest,
});
await bundlePluginEntry({
pluginId: "fusion-plugin-compound-engineering",
srcDir: compoundEngineeringPluginSrc,
destDir: compoundEngineeringPluginDest,
});
if (existsSync(reportsPluginDest)) {
rmSync(reportsPluginDest, { recursive: true, force: true });
}

View File

@@ -549,6 +549,74 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
);
});
it("installs bundled compound engineering plugin when relative path misses cwd", async () => {
const bundledManifest = {
...VALID_MANIFEST,
id: "fusion-plugin-compound-engineering",
name: "Compound Engineering",
};
// Only the staged bundled copy under dist/plugins exists — the
// cwd-relative path must miss so the bundled fallback is exercised.
mockExistsSync.mockImplementation((p: string) => p.includes("dist/plugins/fusion-plugin-compound-engineering/manifest.json"));
mockAccess.mockImplementation((p: string) => {
if (p.includes("dist/plugins/fusion-plugin-compound-engineering")) return Promise.resolve();
return Promise.reject(new Error("not found"));
});
mockReadFile.mockResolvedValue(JSON.stringify(bundledManifest));
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
...INSTALLED_PLUGIN,
id: "fusion-plugin-compound-engineering",
name: "Compound Engineering",
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "./plugins/fusion-plugin-compound-engineering",
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({ id: "fusion-plugin-compound-engineering" }),
path: expect.stringContaining("fusion-plugin-compound-engineering"),
}),
);
});
it("installs bundled cli printing press plugin when relative path misses cwd", async () => {
const bundledManifest = {
...VALID_MANIFEST,
id: "fusion-plugin-cli-printing-press",
name: "CLI Printing Press",
};
// Only the staged bundled copy under dist/plugins exists — the
// cwd-relative path must miss so the bundled fallback is exercised.
mockExistsSync.mockImplementation((p: string) => p.includes("dist/plugins/fusion-plugin-cli-printing-press/manifest.json"));
mockAccess.mockImplementation((p: string) => {
if (p.includes("dist/plugins/fusion-plugin-cli-printing-press")) return Promise.resolve();
return Promise.reject(new Error("not found"));
});
mockReadFile.mockResolvedValue(JSON.stringify(bundledManifest));
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
...INSTALLED_PLUGIN,
id: "fusion-plugin-cli-printing-press",
name: "CLI Printing Press",
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "./plugins/fusion-plugin-cli-printing-press",
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({ id: "fusion-plugin-cli-printing-press" }),
path: expect.stringContaining("fusion-plugin-cli-printing-press"),
}),
);
});
it("returns 404 with helpful message when local and bundled paths are unresolved", async () => {
mockExistsSync.mockReturnValue(false);
mockAccess.mockRejectedValue(new Error("not found"));

View File

@@ -97,6 +97,8 @@ const BUNDLED_PLUGIN_IDS = new Set([
"fusion-plugin-openclaw-runtime",
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
"fusion-plugin-cli-printing-press",
"fusion-plugin-compound-engineering",
]);
function extractBundledPluginId(pathInput: string): string | null {