feat(FN-3590): prioritize workspace source entry for bundled plugins and ad
This merge introduces eval score categorization with a new `eval-scoring.ts` module (FN-3601, FN-3390), hardened bundled plugin entry resolution to prioritize workspace source over installed copies (FN-3590), and documented reply-link threading behavior in the mailbox (FN-3598). It also adds mobile Fusion-Task-Id: FN-3590
This commit is contained in:
5
.changeset/fn-3590-bundled-hermes-settings-fix.md
Normal file
5
.changeset/fn-3590-bundled-hermes-settings-fix.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix bundled runtime plugin settings behavior for fresh installs: bundled Hermes/OpenClaw/Paperclip settings now open without a 404 before install, first save still lazy-installs, missing bundles return explicit server errors, and bundled install entry resolution now prefers workspace source entrypoints over stale build artifacts.
|
||||||
@@ -557,7 +557,7 @@ fn plugin install ./plugins/fusion-plugin-openclaw-runtime
|
|||||||
|
|
||||||
> 💡 In the dashboard, go to **Settings → Plugins → Fusion Plugins**. The **Bundled Plugins** section surfaces Agent Browser, Hermes, Paperclip, OpenClaw, Droid, and Dependency Graph directly from shipped manifests, shows install status, and provides one-click install actions for plugins that are not yet installed.
|
> 💡 In the dashboard, go to **Settings → Plugins → Fusion Plugins**. The **Bundled Plugins** section surfaces Agent Browser, Hermes, Paperclip, OpenClaw, Droid, and Dependency Graph directly from shipped manifests, shows install status, and provides one-click install actions for plugins that are not yet installed.
|
||||||
>
|
>
|
||||||
> ℹ️ Bundled runtime plugins (`fusion-plugin-paperclip-runtime`, `fusion-plugin-hermes-runtime`, `fusion-plugin-openclaw-runtime`) are also auto-installed on the first settings save for that bundled plugin card (lazy install on first `PUT /api/plugins/:id/settings`). They are **not** auto-installed at app boot or npm install time.
|
> ℹ️ Bundled runtime plugins (`fusion-plugin-paperclip-runtime`, `fusion-plugin-hermes-runtime`, `fusion-plugin-openclaw-runtime`) support lazy install semantics in settings: the card can open before installation (initial `GET /api/plugins/:id/settings` returns empty/default settings instead of 404), and the first save triggers auto-install (`PUT /api/plugins/:id/settings`). They are **not** auto-installed at app boot or npm install time. If a bundled asset is genuinely unavailable in the current build, save returns an explicit server error instead of a late plugin-not-found 404.
|
||||||
|
|
||||||
2. Create agents with the appropriate `runtimeConfig`:
|
2. Create agents with the appropriate `runtimeConfig`:
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,18 @@ vi.mock("@fusion/core", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Import SUT after mocks are in place
|
// Import SUT after mocks are in place
|
||||||
import { ensureBundledDependencyGraphPluginInstalled } from "../bundled-plugin-install.js";
|
import {
|
||||||
|
ensureBundledDependencyGraphPluginInstalled,
|
||||||
|
ensureBundledPluginInstalled,
|
||||||
|
resolvePluginEntryPath,
|
||||||
|
} from "../bundled-plugin-install.js";
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
||||||
|
const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime";
|
||||||
|
|
||||||
function makeManifest(overrides?: Partial<{ id: string; version: string }>) {
|
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
|
||||||
return {
|
return {
|
||||||
id: BUNDLED_PLUGIN_ID,
|
id: BUNDLED_PLUGIN_ID,
|
||||||
name: "Dependency Graph",
|
name: "Dependency Graph",
|
||||||
@@ -130,7 +135,11 @@ function makePluginLoader() {
|
|||||||
function setupBundleExists(manifestOverrides?: Partial<{ id: string; version: string }>) {
|
function setupBundleExists(manifestOverrides?: Partial<{ id: string; version: string }>) {
|
||||||
const manifest = makeManifest(manifestOverrides);
|
const manifest = makeManifest(manifestOverrides);
|
||||||
mockExistsSync.mockImplementation((p: string) => {
|
mockExistsSync.mockImplementation((p: string) => {
|
||||||
if (typeof p === "string" && p.endsWith("manifest.json") && p.includes("dist")) return true;
|
if (typeof p !== "string") return false;
|
||||||
|
if (p.endsWith("manifest.json") && p.includes("dist")) return true;
|
||||||
|
if (p.includes("dist") && (p.endsWith("/bundled.js") || p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
|
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
|
||||||
@@ -170,7 +179,9 @@ async function getResolvedBundledPath(): Promise<string> {
|
|||||||
probeLoader as unknown as import("@fusion/core").PluginLoader,
|
probeLoader as unknown as import("@fusion/core").PluginLoader,
|
||||||
);
|
);
|
||||||
const call = probeStore.registerPlugin.mock.calls[0];
|
const call = probeStore.registerPlugin.mock.calls[0];
|
||||||
return (call?.[0] as { path: string })?.path ?? "";
|
const path = (call?.[0] as { path: string })?.path ?? "";
|
||||||
|
expect(path.endsWith(".js") || path.endsWith(".ts")).toBe(true);
|
||||||
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────
|
||||||
@@ -179,6 +190,28 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolvePluginEntryPath", () => {
|
||||||
|
it("prefers src/index.ts over bundled.js when both exist in workspace contexts", () => {
|
||||||
|
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/bundled.js"));
|
||||||
|
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers bundled.js when source entry is unavailable", () => {
|
||||||
|
mockExistsSync.mockImplementation((p: string) => p.endsWith("/bundled.js"));
|
||||||
|
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers src/index.ts over dist/index.js in workspace contexts", () => {
|
||||||
|
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
|
||||||
|
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to dist/index.js when source entry is unavailable", () => {
|
||||||
|
mockExistsSync.mockImplementation((p: string) => p.endsWith("/dist/index.js"));
|
||||||
|
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||||
it("fresh install: registers and loads the plugin when not in DB", async () => {
|
it("fresh install: registers and loads the plugin when not in DB", async () => {
|
||||||
setupBundleExists();
|
setupBundleExists();
|
||||||
@@ -322,4 +355,29 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
|||||||
),
|
),
|
||||||
).rejects.toThrow("Invalid plugin manifest");
|
).rejects.toThrow("Invalid plugin manifest");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("registers Hermes from source entry when both src and dist entries exist", async () => {
|
||||||
|
const manifest = makeManifest({ id: HERMES_PLUGIN_ID, name: "Hermes Runtime" });
|
||||||
|
mockExistsSync.mockImplementation((p: string) => {
|
||||||
|
if (p.endsWith("manifest.json") && p.includes(HERMES_PLUGIN_ID)) return true;
|
||||||
|
if (p.endsWith("/src/index.ts") && p.includes(HERMES_PLUGIN_ID)) return true;
|
||||||
|
if (p.endsWith("/dist/index.js") && p.includes(HERMES_PLUGIN_ID)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
|
||||||
|
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||||
|
|
||||||
|
const store = makePluginStore();
|
||||||
|
const loader = makePluginLoader();
|
||||||
|
|
||||||
|
const result = await ensureBundledPluginInstalled(
|
||||||
|
store as unknown as import("@fusion/core").PluginStore,
|
||||||
|
loader as unknown as import("@fusion/core").PluginLoader,
|
||||||
|
HERMES_PLUGIN_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("installed");
|
||||||
|
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
|
||||||
|
expect(registerCall.path).toContain(`${HERMES_PLUGIN_ID}/src/index.ts`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,16 +60,16 @@ function resolveBundledPluginDir(pluginId: string): string | null {
|
|||||||
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
|
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
|
||||||
* does not allow directory imports, so we must register the explicit file the
|
* does not allow directory imports, so we must register the explicit file the
|
||||||
* loader will dynamic-import. Preference order:
|
* loader will dynamic-import. Preference order:
|
||||||
* 1. ./bundled.js (esbuild-bundled, ships in npm tarball)
|
* 1. ./src/index.ts (workspace/dev source of truth)
|
||||||
* 2. ./dist/index.js
|
* 2. ./bundled.js (esbuild-bundled, ships in npm tarball)
|
||||||
* 3. ./src/index.ts (workspace dev)
|
* 3. ./dist/index.js
|
||||||
* 4. fall back to the directory itself
|
* 4. fall back to the directory itself
|
||||||
*/
|
*/
|
||||||
export function resolvePluginEntryPath(pluginDir: string): string {
|
export function resolvePluginEntryPath(pluginDir: string): string {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
join(pluginDir, "src", "index.ts"),
|
||||||
join(pluginDir, "bundled.js"),
|
join(pluginDir, "bundled.js"),
|
||||||
join(pluginDir, "dist", "index.js"),
|
join(pluginDir, "dist", "index.js"),
|
||||||
join(pluginDir, "src", "index.ts"),
|
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (existsSync(candidate)) {
|
if (existsSync(candidate)) {
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ describe("GET /plugins/:id/settings", () => {
|
|||||||
expect(res.body).toEqual({ apiKey: "secret", enabled: true });
|
expect(res.body).toEqual({ apiKey: "secret", enabled: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for non-existent plugin", async () => {
|
it("returns 404 for non-existent non-bundled plugin", async () => {
|
||||||
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
|
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
|
||||||
);
|
);
|
||||||
@@ -339,6 +339,17 @@ describe("GET /plugins/:id/settings", () => {
|
|||||||
expect(res.body).toHaveProperty("error");
|
expect(res.body).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns empty settings for bundled runtime plugin before first install", async () => {
|
||||||
|
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error('Plugin "fusion-plugin-hermes-runtime" not found'), { code: "ENOENT" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/plugins/fusion-plugin-hermes-runtime/settings");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
it("supports projectId query param scoping", async () => {
|
it("supports projectId query param scoping", async () => {
|
||||||
const scopedPluginStore = createMockPluginStore();
|
const scopedPluginStore = createMockPluginStore();
|
||||||
(scopedPluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(scopedPluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
@@ -1054,6 +1065,23 @@ describe("PUT /plugins/:id/settings auto-install for bundled runtime plugins", (
|
|||||||
expect(ensure).not.toHaveBeenCalled();
|
expect(ensure).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 500 with intentional message when bundled auto-install reports missing bundle", async () => {
|
||||||
|
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
|
new Error("Plugin not found"),
|
||||||
|
);
|
||||||
|
const ensure = vi.fn().mockResolvedValue(false);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildAppWithBundleHook(ensure),
|
||||||
|
"PUT",
|
||||||
|
"/api/plugins/fusion-plugin-hermes-runtime/settings",
|
||||||
|
{ settings: { apiKey: "k" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toContain("is unavailable in this build");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 500 when auto-install throws", async () => {
|
it("returns 500 when auto-install throws", async () => {
|
||||||
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
new Error("Plugin not found"),
|
new Error("Plugin not found"),
|
||||||
|
|||||||
@@ -3247,7 +3247,15 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
const plugin = await pluginStore.getPlugin(id);
|
const plugin = await pluginStore.getPlugin(id);
|
||||||
res.json(plugin.settings);
|
res.json(plugin.settings);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) {
|
const isNotFoundError = err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found");
|
||||||
|
const isBundledFallback = BUNDLED_PLUGIN_RUNTIMES.some((r) => r.pluginId === id);
|
||||||
|
if (isNotFoundError && isBundledFallback) {
|
||||||
|
// Bundled runtime plugins can be surfaced in settings before they've
|
||||||
|
// been lazily installed. Return empty defaults so cards can open.
|
||||||
|
res.json({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNotFoundError) {
|
||||||
throw notFound(`Plugin "${id}" not found`);
|
throw notFound(`Plugin "${id}" not found`);
|
||||||
}
|
}
|
||||||
throw internalError(err instanceof Error ? err.message : "Unknown error");
|
throw internalError(err instanceof Error ? err.message : "Unknown error");
|
||||||
@@ -3606,8 +3614,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
if (!alreadyRegistered) {
|
if (!alreadyRegistered) {
|
||||||
try {
|
try {
|
||||||
await options.ensureBundledPluginInstalled(id);
|
const installOk = await options.ensureBundledPluginInstalled(id);
|
||||||
|
if (!installOk) {
|
||||||
|
throw internalError(
|
||||||
|
`Bundled plugin "${id}" is unavailable in this build and could not be auto-installed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (installErr) {
|
} catch (installErr) {
|
||||||
|
if (installErr instanceof ApiError) {
|
||||||
|
throw installErr;
|
||||||
|
}
|
||||||
throw internalError(
|
throw internalError(
|
||||||
`Failed to auto-install bundled plugin "${id}": ${installErr instanceof Error ? installErr.message : String(installErr)}`,
|
`Failed to auto-install bundled plugin "${id}": ${installErr instanceof Error ? installErr.message : String(installErr)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ const RUNTIME_IGNORE_PATTERNS = [
|
|||||||
/^activity-log\.jsonl$/,
|
/^activity-log\.jsonl$/,
|
||||||
/^settings\.json$/,
|
/^settings\.json$/,
|
||||||
/^logs(?:[\/\\]|$)/,
|
/^logs(?:[\/\\]|$)/,
|
||||||
|
/^tasks(?:[\/\\]|$)/,
|
||||||
|
/^memory(?:[\/\\]|$)/,
|
||||||
|
/^MEMORY\.md$/,
|
||||||
|
/^DREAMS\.md$/,
|
||||||
|
/^\d{4}-\d{2}-\d{2}\.md$/,
|
||||||
|
/^scripts\.json$/,
|
||||||
|
/^update-check\.json$/,
|
||||||
];
|
];
|
||||||
|
|
||||||
function isRuntimePath(relPath) {
|
function isRuntimePath(relPath) {
|
||||||
|
|||||||
Reference in New Issue
Block a user