fix(FN-1952): remove stale task artifacts

This commit is contained in:
Fusion
2026-04-16 21:43:53 -07:00
committed by gsxdsm
parent 390de3931a
commit badd98fcdf
5 changed files with 229 additions and 15 deletions

View File

@@ -4,7 +4,7 @@ import { basename, join, relative, resolve, sep } from "node:path";
const FUSION_DISABLED_EXTENSIONS_KEY = "fusionDisabledExtensions";
export type PiExtensionSource = "fusion-global" | "pi-global" | "fusion-project" | "pi-project";
export type PiExtensionSource = "fusion-global" | "pi-global" | "fusion-project" | "pi-project" | "package";
export interface PiExtensionEntry {
id: string;
@@ -199,7 +199,7 @@ export function getEnabledPiExtensionPaths(cwd: string, home?: string): string[]
.map((entry) => entry.path);
}
export function updatePiExtensionDisabledIds(cwd: string, disabledIds: string[], home?: string): PiExtensionSettings {
export function updatePiExtensionDisabledIds(cwd: string, disabledIds: string[], home?: string, extraKnownIds: string[] = []): PiExtensionSettings {
const settingsPath = getFusionAgentSettingsPath(home);
const existing = (() => {
try {
@@ -209,7 +209,10 @@ export function updatePiExtensionDisabledIds(cwd: string, disabledIds: string[],
}
})();
const known = new Set(discoverPiExtensions(cwd, home).extensions.map((entry) => entry.id));
const known = new Set([
...discoverPiExtensions(cwd, home).extensions.map((entry) => entry.id),
...extraKnownIds.map((entry) => resolve(entry)),
]);
const normalizedDisabledIds = Array.from(new Set(
disabledIds.map((entry) => resolve(entry)).filter((entry) => known.has(entry)),
)).sort();

View File

@@ -2008,20 +2008,21 @@ describe("SettingsModal", () => {
expect(layout!.querySelector(".settings-content")).toBeTruthy();
});
it("has .settings-sidebar with 15 .settings-nav-item buttons for all sections", async () => {
it("has .settings-sidebar with 17 .settings-nav-item buttons for all sections", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const sidebar = container.querySelector(".settings-sidebar");
expect(sidebar).toBeTruthy();
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
// 15 nav items (group headers are not nav items)
expect(navItems.length).toBe(16);
// 17 nav items (group headers are not nav items)
expect(navItems.length).toBe(17);
// Labels include scope icons (Globe for global, Folder for project)
const labels = Array.from(navItems).map((el) => el.textContent?.trim());
expect(labels).toEqual([
"Authentication",
"Pi Extensions",
"Appearance",
"Notifications",
"Node Sync",

View File

@@ -272,6 +272,69 @@ describe("GET /plugins/:id", () => {
});
});
describe("GET /plugins/:id/settings", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("returns plugin settings by id", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
settings: { apiKey: "secret", enabled: true },
});
const res = await GET(buildApp(), "/api/plugins/test-plugin/settings");
expect(res.status).toBe(200);
expect(res.body).toEqual({ apiKey: "secret", enabled: true });
});
it("returns 404 for non-existent plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await GET(buildApp(), "/api/plugins/nonexistent/settings");
expect(res.status).toBe(404);
expect(res.body).toHaveProperty("error");
});
it("supports projectId query param scoping", async () => {
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
settings: { scoped: true },
});
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await GET(buildApp(), "/api/plugins/test-plugin/settings?projectId=proj_123");
expect(res.status).toBe(200);
expect(res.body).toEqual({ scoped: true });
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("POST /plugins", () => {
let store: TaskStore;
let pluginStore: PluginStore;
@@ -586,7 +649,94 @@ describe("POST /plugins/:id/disable", () => {
});
});
describe("PATCH /plugins/:id/settings", () => {
describe("POST /plugins/:id/reload", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginRunner: { getPluginRoutes: ReturnType<typeof vi.fn>; reloadPlugin: ReturnType<typeof vi.fn> };
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([]),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
};
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp(includeRunner = true) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
pluginRunner: includeRunner ? pluginRunner : undefined,
}));
return app;
}
it("reloads a started plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "started" })
.mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "started", updatedAt: "2026-02-01T00:00:00.000Z" });
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(200);
expect(pluginRunner.reloadPlugin).toHaveBeenCalledWith("test-plugin");
expect(res.body.id).toBe("test-plugin");
});
it("returns 404 when plugin is not found", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/nonexistent/reload", {});
expect(res.status).toBe(404);
});
it("returns 400 when plugin is not started", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
state: "installed",
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Use enable instead");
});
it("returns 500 when plugin runner is unavailable", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
state: "started",
});
const res = await REQUEST(buildApp(false), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(500);
expect(res.body.error).toContain("Plugin runner not available");
});
it("returns 500 when reload operation fails", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
state: "started",
});
pluginRunner.reloadPlugin.mockRejectedValueOnce(new Error("boom"));
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(500);
expect(res.body.error).toContain("Reload failed: boom");
});
});
describe("PUT /plugins/:id/settings", () => {
let store: TaskStore;
let pluginStore: PluginStore;
@@ -613,7 +763,7 @@ describe("PATCH /plugins/:id/settings", () => {
settings: { apiKey: "new-secret" },
});
const res = await REQUEST(buildApp(), "PATCH", "/api/plugins/test-plugin/settings", {
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/test-plugin/settings", {
settings: { apiKey: "new-secret" },
});
@@ -622,7 +772,7 @@ describe("PATCH /plugins/:id/settings", () => {
});
it("returns 400 when settings is missing", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/plugins/test-plugin/settings", {});
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/test-plugin/settings", {});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'settings'");
@@ -633,7 +783,7 @@ describe("PATCH /plugins/:id/settings", () => {
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await REQUEST(buildApp(), "PATCH", "/api/plugins/nonexistent/settings", {
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/nonexistent/settings", {
settings: { key: "value" },
});
@@ -645,7 +795,7 @@ describe("PATCH /plugins/:id/settings", () => {
new Error("Settings validation failed: setting 'apiKey' is required"),
);
const res = await REQUEST(buildApp(), "PATCH", "/api/plugins/test-plugin/settings", {
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/test-plugin/settings", {
settings: {},
});

View File

@@ -18,7 +18,7 @@ import * as nodeFs from "node:fs";
import { promisify } from "node:util";
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, readMemory, writeMemory, MemoryBackendError, discoverPiExtensions, updatePiExtensionDisabledIds } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, readMemory, writeMemory, MemoryBackendError, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -13218,6 +13218,27 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/plugins/:id/settings
* Get plugin settings by plugin ID.
* Query: { projectId?: string }
*/
router.get("/plugins/:id/settings", async (req: Request, res: Response) => {
const { store: scopedStore } = await getProjectContext(req);
const pluginStore = scopedStore.getPluginStore();
const id = req.params.id as string;
try {
const plugin = await pluginStore.getPlugin(id);
res.json(plugin.settings);
} catch (err: unknown) {
if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) {
throw notFound(`Plugin "${id}" not found`);
}
throw internalError(err instanceof Error ? err.message : "Unknown error");
}
});
/**
* POST /api/plugins
* Create or register a plugin.
@@ -13396,11 +13417,49 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
/**
* PATCH /api/plugins/:id/settings
* POST /api/plugins/:id/reload
* Reload a running plugin with updated code.
* Body: { projectId?: string }
*/
router.post("/plugins/:id/reload", async (req: Request, res: Response) => {
const { store: scopedStore } = await getProjectContext(req);
const pluginStore = scopedStore.getPluginStore();
const id = req.params.id as string;
let plugin: import("@fusion/core").PluginInstallation;
try {
plugin = await pluginStore.getPlugin(id);
} catch (err: unknown) {
if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) {
throw notFound(`Plugin "${id}" not found`);
}
throw internalError(err instanceof Error ? err.message : "Unknown error");
}
if (plugin.state !== "started") {
throw badRequest("Plugin is not currently loaded. Use enable instead.");
}
if (!options?.pluginRunner?.reloadPlugin) {
throw internalError("Plugin runner not available");
}
try {
await options.pluginRunner.reloadPlugin(id);
} catch (reloadErr: unknown) {
throw internalError(`Reload failed: ${reloadErr instanceof Error ? reloadErr.message : String(reloadErr)}`);
}
const updatedPlugin = await pluginStore.getPlugin(id);
res.json(updatedPlugin);
});
/**
* PUT /api/plugins/:id/settings
* Update plugin settings.
* Body: { settings: Record<string, unknown>, projectId?: string }
*/
router.patch("/plugins/:id/settings", async (req: Request, res: Response) => {
router.put("/plugins/:id/settings", async (req: Request, res: Response) => {
const { store: scopedStore } = await getProjectContext(req);
const pluginStore = scopedStore.getPluginStore();
const id = req.params.id as string;

View File

@@ -175,9 +175,10 @@ export interface ServerOptions {
pluginStore?: import("@fusion/core").PluginStore;
/** Optional PluginLoader for plugin lifecycle management */
pluginLoader?: import("@fusion/core").PluginLoader;
/** Optional PluginRunner for plugin hooks and tools */
/** Optional PluginRunner for plugin hooks, routes, and lifecycle operations */
pluginRunner?: {
getPluginRoutes(): Array<{ pluginId: string; route: import("@fusion/core").PluginRouteDefinition }>;
reloadPlugin?(pluginId: string): Promise<unknown>;
};
/** Optional ChatStore for chat session management */
chatStore?: import("@fusion/core").ChatStore;