Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/workflow-graph-editor-and-bundled-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,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.
- Plugins installed from Settings now load instead of erroring with "Plugin entry must be a file, got directory": the dashboard install routes register the plugin's loadable entry file (`bundled.js`/`dist/index.js`/`src/index.ts`) rather than the package directory, and enabling a plugin heals legacy directory-path registrations in place.
4 changes: 4 additions & 0 deletions CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ A plugin that ships inside the Fusion distribution itself rather than being inst
*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.

### Plugin Entry
The single loadable file persisted as a plugin's path and dynamically imported by the loader. The contract is strict: a package directory is never a valid entry (ESM cannot import directories), so every install surface must resolve a concrete file before persisting, preferring the shipped bundle, then a prebuilt output, then raw workspace source. Legacy registrations that stored a directory are healed in place — re-pointed at a resolved entry — the next time the plugin is enabled or auto-installed.

## 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.*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ symptoms:
root_cause: incomplete_setup
resolution_type: code_fix
severity: medium
tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift]
last_updated: 2026-06-05
tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift, entry-file, fs-mock]
---

# Bundled plugins must be registered in 4 independent places — they drift
Expand Down Expand Up @@ -63,6 +64,14 @@ await bundlePluginEntry({
});
```

## Follow-up failure: directory registered as plugin path

Fixing the fallback surfaced a second, independent bug (fixed in PR #1428): both dashboard install routes registered the **manifest directory** as the plugin path, but since FN-4128 the loader requires a loadable entry **file** (Node ESM cannot import directories) — enable then failed with `Plugin entry must be a file, got directory: <dir>`. Only the CLI startup path had been migrated to `resolvePluginEntryPath` (`bundled.js` → `dist/index.js` → `src/index.ts`), which is why CLI-auto-installed plugins worked and Settings-installed ones never did. Fix: both install routes now resolve and register the entry file (helper added to `@fusion/core`; 400 with "no loadable entry file" when none exists), and **both** enable routes heal legacy directory-path rows in place before `loadPlugin` — mirroring the CLI's startup heal — so pre-fix broken registrations self-repair on first enable without a migration.

### Trap: vitest fs mocks don't reach externalized workspace deps

Moving `resolvePluginEntryPath` to `@fusion/core` and re-exporting from the CLI broke the CLI's tests: `vi.mock("node:fs")` in the CLI package does **not** intercept fs calls made inside the externalized `@fusion/core` import (vitest only inlines/mocks modules in the test package's transform graph — the dashboard package inlines core, the CLI doesn't). Resolution: the CLI keeps an intentionally duplicated local copy (its fs mocks work against it), both copies carry keep-in-sync comments, and a **real-fs drift-guard test** (`packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts`) imports both copies and asserts identical resolution across real temp-dir layouts — each candidate alone, precedence pairs, all three, and the no-entry → `null` case. Real directories are the only seam that exercises both implementations equally; a candidate-list change applied to one copy but not the other now fails CI.

## 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.
Expand All @@ -71,9 +80,13 @@ The Settings card sends a relative `./plugins/<id>` path. The server resolves it

- **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.
- **Pin assertions to the exact contract, not substring containment.** `stringContaining(pluginId)` passed for both the correct entry-file path and the buggy directory path — when a mock or matcher can satisfy both the correct and the buggy value, the test proves nothing. Route tests now assert the registered path ends in an entry-file suffix, cover the `dist/index.js` and `src/index.ts` fallbacks, and the 400 no-entry branch.
- When duplicating a helper is forced by test infrastructure (fs mocks vs externalized deps), add a real-fs drift-guard test that runs every copy against the same on-disk fixtures and asserts identical output.
- 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
- PR #1423 — the registration-drift fix
- PR #1428 — the entry-file/heal follow-up fix
- Issue #1096 — same Settings-install bundled-plugin failure family (missing-bundle symptom for the Paperclip runtime in global npm installs); different root cause
- Commit `ff0750cd1` — added CE/Roadmap to the UI list (2 of 4 registrations)
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Drift guard for the intentionally duplicated resolvePluginEntryPath.
*
* The CLI keeps a local copy in bundled-plugin-install.ts (so its fs mocks
* work in tests) while @fusion/core owns the copy used by the dashboard
* install/enable routes. This test runs both against real on-disk layouts and
* asserts identical results, so a candidate-list change applied to one copy
* but not the other fails CI instead of silently diverging.
*
* No fs mocks here on purpose — vitest module mocks don't reach the
* externalized @fusion/core import, so real temp directories are the only
* seam that exercises both implementations equally.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js";
import { resolvePluginEntryPath as coreResolve } from "@fusion/core";

describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () => {
let dir: string;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "entry-path-sync-"));
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

function touch(relative: string) {
const full = join(dir, relative);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, "// entry\n");
}

const layouts: Array<{ name: string; files: string[]; expected: string | null }> = [
{ name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" },
{ name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" },
{ name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" },
{ name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" },
{ name: "dist preferred over src", files: ["dist/index.js", "src/index.ts"], expected: "dist/index.js" },
{ name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" },
{ name: "no entry files", files: ["README.md"], expected: null },
];

for (const layout of layouts) {
it(`resolves identically for: ${layout.name}`, () => {
for (const f of layout.files) touch(f);
const expected = layout.expected === null ? null : join(dir, layout.expected);

expect(cliResolve(dir)).toBe(expected);
expect(coreResolve(dir)).toBe(expected);
});
}
});
3 changes: 3 additions & 0 deletions packages/cli/src/plugins/bundled-plugin-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ function resolveBundledPluginDir(pluginId: string): string | null {
* Returns null when the directory exists but none of the loadable entry files
* are present. Callers must treat that as a missing bundle rather than
* persisting a directory path that Node cannot import.
*
* Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts),
* which the dashboard install/enable routes use for the same contract.
*/
export function resolvePluginEntryPath(pluginDir: string): string | null {
const candidates = [
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,7 @@ export {
} from "./plugin-types.js";
export { PluginStore } from "./plugin-store.js";
export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js";
export { PluginLoader } from "./plugin-loader.js";
export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js";
export { scanPluginSecurity } from "./plugin-security-scan.js";
export type { PluginSecurityScanResult, PluginSecurityFinding } from "./plugin-security-scan.js";
export type {
Expand Down
32 changes: 31 additions & 1 deletion packages/core/src/plugin-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
* - Error isolation (plugin crashes don't crash the loader)
*/

import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
import { existsSync } from "node:fs";
import { stat } from "node:fs/promises";
import { copyFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
Expand Down Expand Up @@ -48,6 +49,35 @@ import { scanPluginSecurity } from "./plugin-security-scan.js";
const MINIMUM_FUSION_VERSION = "0.1.0";
let moduleImportVersion = 0;

/**
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
* does not allow directory imports, so the registered plugin path must be the
* explicit file the loader will dynamic-import. Preference order:
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball)
* 2. ./dist/index.js (legacy prebuilt fallback)
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists)
*
* Returns null when the directory exists but none of the loadable entry files
* are present. Callers must treat that as a missing/unloadable plugin rather
* than persisting a directory path that Node cannot import.
*
* Keep in sync with resolvePluginEntryPath in the CLI's
* bundled-plugin-install.ts, which keeps a local copy so its fs mocks work.
*/
export function resolvePluginEntryPath(pluginDir: string): string | null {
const candidates = [
join(pluginDir, "bundled.js"),
join(pluginDir, "dist", "index.js"),
join(pluginDir, "src", "index.ts"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
Comment thread
gsxdsm marked this conversation as resolved.

export interface PluginLoaderOptions {
/** Plugin store for persistence */
pluginStore: PluginStore;
Expand Down
2 changes: 1 addition & 1 deletion packages/dashboard/app/components/TaskFieldsSection.css
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
background: var(--card);
transition: transform 0.15s ease;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
batchUpdateTaskModels: vi.fn(),
fetchNodes: vi.fn().mockResolvedValue([]),
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
}));

import { fetchTaskDetail, batchUpdateTaskModels, fetchNodes } from "../../api";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,13 @@ describe("auto-merge toggle mobile blank regression", () => {
expectBoardVisible();

act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});

board.scrollLeft = 240;
act(() => {
visualViewport.dispatchResize();
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(0);

Expand All @@ -227,7 +227,7 @@ describe("auto-merge toggle mobile blank regression", () => {
board.scrollLeft = 240;
act(() => {
visualViewport.dispatchResize();
vi.runAllTimers();
vi.runOnlyPendingTimers();
});

expectBoardVisible();
Expand All @@ -248,7 +248,7 @@ describe("auto-merge toggle mobile blank regression", () => {
const board = document.querySelector("main.board") as HTMLElement;

act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});

const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
Expand All @@ -260,7 +260,7 @@ describe("auto-merge toggle mobile blank regression", () => {
board.scrollLeft = 180;
act(() => {
visualViewport.dispatchResize();
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expectBoardVisible();
expect(board.scrollLeft).toBe(0);
Expand All @@ -270,7 +270,7 @@ describe("auto-merge toggle mobile blank regression", () => {
board.scrollLeft = 180;
act(() => {
visualViewport.dispatchResize();
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expectBoardVisible();
expect(board.scrollLeft).toBe(0);
Expand All @@ -296,7 +296,7 @@ describe("auto-merge toggle mobile blank regression", () => {
);

act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});

expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("true");
Expand All @@ -322,7 +322,7 @@ describe("auto-merge toggle mobile blank regression", () => {
const board = document.querySelector("main.board") as HTMLElement;

act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});

fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
Expand All @@ -332,7 +332,7 @@ describe("auto-merge toggle mobile blank regression", () => {
Object.defineProperty(pageShow, "persisted", { configurable: true, value: true });
act(() => {
window.dispatchEvent(pageShow);
vi.runAllTimers();
vi.runOnlyPendingTimers();
});

expectBoardVisible();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
board.scrollLeft = 500;

act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(0);
expect(raf).toHaveBeenCalled();
Expand All @@ -130,7 +130,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {

const board = document.querySelector("main.board") as HTMLElement;
act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(0);

Expand All @@ -140,7 +140,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
window.dispatchEvent(pageShow);

act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(0);

Expand Down Expand Up @@ -190,7 +190,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
window.dispatchEvent(pageShow);

act(() => {
vi.runAllTimers();
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(500);
expect(addEventListenerSpy).not.toHaveBeenCalledWith("pageshow", expect.any(Function));
Expand Down
1 change: 1 addition & 0 deletions packages/dashboard/src/__tests__/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ vi.mock("@fusion/core", () => ({
summarizeTitle: vi.fn(),
AgentStore: vi.fn(),
ChatStore: vi.fn(),
registerTraitHookImpl: vi.fn(),
}));

describe("resolveFileReferences", () => {
Expand Down
Loading
Loading