fix(cli): reuse project stores for skill discovery (#2102)

## Summary

- reuse the dashboard command's backend-aware per-project `TaskStore`
cache during project-scoped plugin skill discovery
- obtain plugin state through `TaskStore.getPluginStore()` instead of
constructing bare SQLite-default `PluginStore` / `TaskStore` instances
- keep cached project stores alive for the dashboard process while still
stopping request-scoped plugin loaders
- add a regression covering the real Skills adapter callback and refresh
the dashboard test fixture with `getAsyncLayer()`

## Root cause

`GET /api/skills/discovered` resolved the project correctly, then
`getProjectScopedPluginSkills()` constructed new stores without an
`AsyncDataLayer`. After `VAL-REMOVAL-005`, that enters the physically
removed synchronous SQLite runtime and returns HTTP 500 even when
PostgreSQL health, projects, tasks, and both project engines are
healthy.

The existing route tests mocked the Skills adapter callback, so they did
not exercise this CLI wiring.

## Verification

- targeted dashboard regression: 1 passed, 91 skipped
- `pnpm lint`
- `pnpm --filter @runfusion/fusion typecheck`
- `pnpm --filter @runfusion/fusion build`
- `pnpm check:changesets --strict`
- `git diff --check`

Live Atlas validation against the migrated embedded PostgreSQL runtime:

- `/api/skills/discovered?projectId=proj_84f4645c2da64288`: HTTP 200, 36
skills
- `/api/skills/discovered?projectId=proj_7538a9dd46c24c5f`: HTTP 200, 36
skills
- local dashboard and Tailscale dashboard: HTTP 200
- controlled SIGTERM: launchd restarted the dashboard and both Skills
routes remained healthy


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Fixed dashboard project-scoped plugin-skill discovery in PostgreSQL
mode with safer store reuse/teardown and request-scoped plugin-loader
lifecycle.
- Improved dashboard cleanup to avoid duplicate concurrent store closes
and ensured proper shutdown behavior per root type.
- Made `fusion_runtime` role creation race-safe during concurrent
PostgreSQL migrations.
- **New Features**
- Added `persistRuntimeState` option to control whether plugin runtime
state changes are persisted.
- **Tests**
- Expanded dashboard and core hot-reload tests to verify scoped,
non-persistent runtime behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Phil Larson
2026-07-14 18:20:31 -07:00
committed by GitHub
parent 9bdbdc5f16
commit be55d0a987
10 changed files with 229 additions and 17 deletions

View File

@@ -0,0 +1,8 @@
---
"@runfusion/fusion": patch
"@fusion/core": patch
---
summary: Fix dashboard skill discovery lifecycle in PostgreSQL mode.
category: fix
dev: Reuse and close backend-aware project stores, keep request-scoped discovery loaders from mutating persistent plugin runtime state, and make cluster-wide PostgreSQL runtime-role creation race-safe.

View File

@@ -320,6 +320,8 @@ const mocks = vi.hoisted(() => {
const pluginLoader = {
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
getPluginSkills: vi.fn().mockReturnValue([]),
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
@@ -349,6 +351,7 @@ const mocks = vi.hoisted(() => {
};
const refreshAllCustomProviderModels = vi.fn().mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 });
const createSkillsAdapterMock = vi.fn().mockReturnValue(undefined);
const agentSemaphoreCtor = vi.fn().mockImplementation(function () {
return {
@@ -470,6 +473,7 @@ const mocks = vi.hoisted(() => {
missionAutopilotInstances,
missionExecutionLoopInstances,
notifierInstances,
pluginLoaderInstances,
projectEngineInstances,
listenCalls,
globalSettingsStoreInstance,
@@ -500,6 +504,7 @@ const mocks = vi.hoisted(() => {
authStorage,
modelRegistry,
refreshAllCustomProviderModels,
createSkillsAdapterMock,
reset() {
taskStores.length = 0;
automationStores.length = 0;
@@ -574,7 +579,7 @@ resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved
GitHubClient: vi.fn().mockImplementation(function () {
return {};
}),
createSkillsAdapter: vi.fn().mockReturnValue(undefined),
createSkillsAdapter: mocks.createSkillsAdapterMock,
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
refreshAllCustomProviderModels: mocks.refreshAllCustomProviderModels,
@@ -832,6 +837,32 @@ describe("runDaemon", () => {
await triggerSignal("SIGINT");
});
/*
* FNXC:PluginSkillsPostgres 2026-07-14-17:47:
* `fn daemon` skill discovery is metadata-only. Its request-scoped loader must not persist synthetic plugin starts, stops, or errors.
*/
it("keeps request-scoped plugin skill discovery read-only", async () => {
await runDaemon({});
const adapterOptions = mocks.createSkillsAdapterMock.mock.calls.at(-1)?.[0] as {
getPluginSkills?: (rootDir: string, resolvedProjectStore: (typeof mocks.taskStores)[number]) => Promise<unknown[]>;
};
const resolvedProjectStore = mocks.taskStores[0];
resolvedProjectStore.getPluginStore().listPlugins.mockResolvedValue([
{ id: "enabled-plugin", updatedAt: "2026-07-14T00:00:00.000Z" },
]);
mocks.pluginLoaderCtor.mockClear();
await expect(adapterOptions.getPluginSkills?.("/repo-secondary", resolvedProjectStore)).resolves.toEqual([]);
expect(mocks.pluginLoaderCtor).toHaveBeenCalledWith({
pluginStore: resolvedProjectStore.getPluginStore(),
taskStore: resolvedProjectStore,
persistRuntimeState: false,
});
expect(mocks.pluginLoaderInstances.at(-1)?.stopAllPlugins).toHaveBeenCalledOnce();
await triggerSignal("SIGINT");
});
// FNXC:DaemonSignalExit 2026-07-10-14:00: a memory-pressure SIGTERM must exit
// non-zero (128+signal) so a `Restart=on-failure` supervisor restarts the
// daemon instead of treating the kill as a clean stop. Regression for the

View File

@@ -42,6 +42,10 @@ const { mockSuperviseSpawn } = vi.hoisted(() => ({
})),
}));
const { mockCreateSkillsAdapter } = vi.hoisted(() => ({
mockCreateSkillsAdapter: vi.fn().mockReturnValue(undefined),
}));
/*
FNXC:SystemPanel 2026-07-12-14:35:
Fake attached child for runDashboardSupervised: the supervisor now uses a
@@ -180,6 +184,7 @@ function makeMockStore() {
updateTask: vi.fn().mockResolvedValue({}),
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
getAsyncLayer: vi.fn().mockReturnValue(null),
getGlobalSettingsStore: vi.fn(() => ({
getSettings: mockGlobalSettingsGetSettings,
updateSettings: mockGlobalSettingsUpdateSettings,
@@ -264,6 +269,8 @@ vi.mock("@fusion/core", async (importOriginal) => {
return {
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
getPluginSkills: vi.fn().mockReturnValue([]),
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
@@ -425,7 +432,7 @@ resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved
getPrMergeStatus: mockGetPrMergeStatus,
mergePr: mockMergePr,
})),
createSkillsAdapter: vi.fn().mockReturnValue(undefined),
createSkillsAdapter: mockCreateSkillsAdapter,
getCliPackageVersion: mockGetCliPackageVersion,
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
@@ -915,6 +922,54 @@ async function runDashboard(...args: Parameters<typeof runDashboardImpl>): Retur
// ── Tests ───────────────────────────────────────────────────────────
describe("runDashboard — project-scoped plugin skills", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("reuses a backend-aware project store instead of constructing a SQLite PluginStore", async () => {
vi.stubEnv("FUSION_NO_EMBEDDED_PG", "1");
try {
const dashboard = await runDashboard(0, { open: false });
const adapterOptions = mockCreateSkillsAdapter.mock.calls.at(-1)?.[0] as
| { getPluginSkills?: (rootDir: string, resolvedProjectStore?: ReturnType<typeof makeMockStore>) => Promise<unknown[]> }
| undefined;
expect(adapterOptions?.getPluginSkills).toBeTypeOf("function");
const { PluginLoader, PluginStore, TaskStore } = await import("@fusion/core");
const scopedStore = makeMockStore();
vi.mocked(scopedStore.getPluginStore().listPlugins).mockResolvedValue([
{ id: "enabled-plugin", updatedAt: "2026-07-14T00:00:00.000Z" },
]);
const taskStoreConstructor = vi.mocked(TaskStore);
taskStoreConstructor.mockClear();
const pluginStoreConstructor = vi.mocked(PluginStore);
pluginStoreConstructor.mockClear();
const pluginLoaderConstructor = vi.mocked(PluginLoader);
pluginLoaderConstructor.mockClear();
await expect(adapterOptions!.getPluginSkills!("/tmp/other-project", scopedStore)).resolves.toEqual([]);
expect(pluginStoreConstructor).not.toHaveBeenCalled();
expect(taskStoreConstructor).not.toHaveBeenCalled();
expect(pluginLoaderConstructor).toHaveBeenCalledWith({
pluginStore: scopedStore.getPluginStore(),
taskStore: scopedStore,
persistRuntimeState: false,
});
const scopedPluginLoader = pluginLoaderConstructor.mock.results.at(-1)?.value as {
stopAllPlugins: ReturnType<typeof vi.fn>;
};
expect(scopedPluginLoader.stopAllPlugins).toHaveBeenCalledWith();
dashboard.dispose();
expect(scopedStore.close).not.toHaveBeenCalled();
} finally {
vi.unstubAllEnvs();
}
});
});
describe("runDashboard — startup model sync", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -352,6 +352,8 @@ const mocks = vi.hoisted(() => {
const pluginLoader = {
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
getPluginSkills: vi.fn().mockReturnValue([]),
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
@@ -381,6 +383,7 @@ const mocks = vi.hoisted(() => {
};
const refreshAllCustomProviderModels = vi.fn().mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 });
const createSkillsAdapterMock = vi.fn().mockReturnValue(undefined);
const agentSemaphoreCtor = vi.fn().mockImplementation(function () {
return {
@@ -532,6 +535,7 @@ const mocks = vi.hoisted(() => {
missionAutopilotInstances,
missionExecutionLoopInstances,
notifierInstances,
pluginLoaderInstances,
projectEngineInstances,
listenCalls,
taskStoreCtor,
@@ -560,6 +564,7 @@ const mocks = vi.hoisted(() => {
authStorage,
modelRegistry,
refreshAllCustomProviderModels,
createSkillsAdapterMock,
globalSettingsGetSettings,
reset() {
taskStores.length = 0;
@@ -634,7 +639,7 @@ resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved
GitHubClient: vi.fn().mockImplementation(function () {
return {};
}),
createSkillsAdapter: vi.fn().mockReturnValue(undefined),
createSkillsAdapter: mocks.createSkillsAdapterMock,
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
refreshAllCustomProviderModels: mocks.refreshAllCustomProviderModels,
@@ -920,6 +925,32 @@ describe("runServe", () => {
await triggerSignal("SIGINT");
});
/*
* FNXC:PluginSkillsPostgres 2026-07-14-17:47:
* `fn serve` skill discovery is metadata-only. Its request-scoped loader must not persist synthetic plugin starts, stops, or errors.
*/
it("keeps request-scoped plugin skill discovery read-only", async () => {
await runServe(0, {});
const adapterOptions = mocks.createSkillsAdapterMock.mock.calls.at(-1)?.[0] as {
getPluginSkills?: (rootDir: string, resolvedProjectStore: (typeof mocks.taskStores)[number]) => Promise<unknown[]>;
};
const resolvedProjectStore = mocks.taskStores[0];
resolvedProjectStore.getPluginStore().listPlugins.mockResolvedValue([
{ id: "enabled-plugin", updatedAt: "2026-07-14T00:00:00.000Z" },
]);
mocks.pluginLoaderCtor.mockClear();
await expect(adapterOptions.getPluginSkills?.("/repo-secondary", resolvedProjectStore)).resolves.toEqual([]);
expect(mocks.pluginLoaderCtor).toHaveBeenCalledWith({
pluginStore: resolvedProjectStore.getPluginStore(),
taskStore: resolvedProjectStore,
persistRuntimeState: false,
});
expect(mocks.pluginLoaderInstances.at(-1)?.stopAllPlugins).toHaveBeenCalledOnce();
await triggerSignal("SIGINT");
});
it("passes remote-capable engine hooks into headless createServer for fn serve parity", async () => {
const { createServer } = await import("@fusion/dashboard");

View File

@@ -776,7 +776,15 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
const scopedPluginStore = targetStore.getPluginStore();
const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: targetStore });
/*
* FNXC:PluginSkillsPostgres 2026-07-14-17:47:
* Request-scoped skill discovery is read-only across every CLI server surface. Loading and stopping plugins here must not rewrite durable runtime state for the target project.
*/
const scopedPluginLoader = new PluginLoader({
pluginStore: scopedPluginStore,
taskStore: targetStore,
persistRuntimeState: false,
});
try {
await scopedPluginStore.init();
const { errors } = await scopedPluginLoader.loadAllPlugins();

View File

@@ -990,8 +990,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// FNXC:PostgresCutover 2026-07-05-12:00: non-cwd project stores must boot
// through the PostgreSQL startup factory; bare `new TaskStore` throws in
// backend mode (SQLite runtime removed under VAL-REMOVAL-005). Stores are
// cached for the TUI process lifetime; pools are released at process exit.
// cached for the dashboard process lifetime and explicitly closed during
// dashboard disposal/shutdown.
const projectStores = new Map<string, TaskStore>();
const projectStoreShutdowns = new Map<string, () => Promise<void>>();
let projectStoresClosePromise: Promise<void> | undefined;
async function getProjectStore(projectPath: string): Promise<TaskStore> {
const cached = projectStores.get(projectPath);
if (cached) return cached;
@@ -1003,6 +1006,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const boot = await createTaskStoreForBackend({ rootDir: projectPath });
if (boot) {
projectStore = boot.taskStore;
projectStoreShutdowns.set(projectPath, boot.shutdown);
} else {
projectStore = new TaskStore(projectPath);
await projectStore.init();
@@ -1011,6 +1015,23 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
projectStores.set(projectPath, projectStore);
return projectStore;
}
async function closeProjectStores(): Promise<void> {
projectStoresClosePromise ??= (async () => {
const stores = Array.from(projectStores.entries()).filter(([, projectStore]) => projectStore !== store);
projectStores.clear();
const shutdowns = new Map(projectStoreShutdowns);
projectStoreShutdowns.clear();
await Promise.allSettled(stores.map(async ([projectPath, projectStore]) => {
const shutdown = shutdowns.get(projectPath);
if (shutdown) {
await shutdown();
} else {
await projectStore.close();
}
}));
})();
await projectStoresClosePromise;
}
// ── U11: resolve per-task workflow column flags for the TUI (flag-ON only) ──
//
@@ -1831,6 +1852,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
>();
const getProjectScopedPluginSkills = async (rootDir: string, resolvedProjectStore?: TaskStore): Promise<ReturnType<PluginLoader["getPluginSkills"]>> => {
const normalizedRootDir = pathResolve(rootDir);
/*
* FNXC:PluginSkillsPostgres 2026-07-14-23:45:
* Skill discovery must use the backend-aware project store resolved by the
* dashboard route. Direct PluginStore/TaskStore construction enters the
* removed SQLite runtime under PostgreSQL (VAL-REMOVAL-005).
*/
const targetStore = resolvedProjectStore ?? (normalizedRootDir === pathResolve(store.getRootDir()) ? store : undefined);
if (!targetStore) return [];
const stateStore = targetStore.getPluginStore();
@@ -1865,7 +1892,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
const scopedPluginStore = targetStore.getPluginStore();
const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: targetStore });
const scopedPluginLoader = new PluginLoader({
pluginStore: scopedPluginStore,
taskStore: targetStore,
persistRuntimeState: false,
});
try {
await scopedPluginStore.init();
const { errors } = await scopedPluginLoader.loadAllPlugins();
@@ -1935,6 +1966,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
void dashboardBackendShutdown!().catch(() => undefined);
});
}
disposeCallbacks.push(() => {
void closeProjectStores();
});
// ── createServer: deferred until engine is conditionally started ────
//
@@ -2297,6 +2331,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
closeCentralCoreBestEffort(centralCoreForEngine, `shutdown (${signal})`),
);
await timeShutdownStep("closeProjectStores", () => closeProjectStores());
store.close();
process.exit(shutdownExitCode);
};
@@ -2630,6 +2665,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
);
}
await timeShutdownStep("closeProjectStores", () => closeProjectStores());
store.close();
process.exit(shutdownExitCode);
};

View File

@@ -886,7 +886,15 @@ export async function runServe(
}
const scopedPluginStore = targetStore.getPluginStore();
const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: targetStore });
/*
* FNXC:PluginSkillsPostgres 2026-07-14-17:47:
* Request-scoped skill discovery is read-only across every CLI server surface. Loading and stopping plugins here must not rewrite durable runtime state for the target project.
*/
const scopedPluginLoader = new PluginLoader({
pluginStore: scopedPluginStore,
taskStore: targetStore,
persistRuntimeState: false,
});
try {
await scopedPluginStore.init();
const { errors } = await scopedPluginLoader.loadAllPlugins();

View File

@@ -304,6 +304,21 @@ describe("PluginLoader Hot-Reload", () => {
await expect(pluginLoader.stopPlugin("nonexistent")).resolves.not.toThrow();
expect(pluginLoader.isPluginLoaded("nonexistent")).toBe(false);
});
it("unloads request-scoped plugins without persisting a stopped runtime state", async () => {
pluginLoader = new PluginLoader({
pluginStore: mockPluginStore,
taskStore: mockTaskStore,
persistRuntimeState: false,
});
await pluginLoader.loadPlugin("hot-reload-test");
expect((mockPluginStore as any)._installation.state).toBe("installed");
await pluginLoader.stopAllPlugins();
expect(pluginLoader.isPluginLoaded("hot-reload-test")).toBe(false);
expect((mockPluginStore as any)._installation.state).toBe("installed");
});
});
describe("reloadPlugin() - hot reload", () => {

View File

@@ -158,6 +158,8 @@ export interface PluginLoaderOptions {
pluginDirs?: string[];
/** npm prefix for resolving packages */
npmPrefix?: string;
/** Persist started/stopped/error runtime state transitions (default true). */
persistRuntimeState?: boolean;
}
/**
@@ -213,6 +215,15 @@ export class PluginLoader extends EventEmitter<{
super();
}
private async updatePluginState(
pluginId: string,
state: PluginInstallation["state"],
error?: string,
): Promise<void> {
if (this.options.persistRuntimeState === false) return;
await this.options.pluginStore.updatePluginState(pluginId, state, error);
}
private getProjectRoot(): string {
return this.options.taskStore.getRootDir();
}
@@ -341,7 +352,7 @@ export class PluginLoader extends EventEmitter<{
if (["blocked", "error", "unavailable"].includes(scanResult.verdict)) {
const errorMessage = `Security scan ${scanResult.verdict}: ${scanResult.summary}`;
await this.options.pluginStore.updatePluginState(pluginId, "error", errorMessage);
await this.updatePluginState(pluginId, "error", errorMessage);
this.emit("plugin:error", { pluginId, error: new Error(errorMessage) });
throw new Error(errorMessage);
}
@@ -378,7 +389,7 @@ export class PluginLoader extends EventEmitter<{
await this.resolveDependencies(plugin);
// Update state to started
await this.options.pluginStore.updatePluginState(pluginId, "started");
await this.updatePluginState(pluginId, "started");
// Update plugin state locally and store
plugin.state = "started";
@@ -394,7 +405,7 @@ export class PluginLoader extends EventEmitter<{
this.plugins.delete(pluginId);
this.pluginRoots.delete(pluginId);
const errorMsg = loadErr instanceof Error ? loadErr.message : String(loadErr);
await this.options.pluginStore.updatePluginState(
await this.updatePluginState(
pluginId,
"error",
`onLoad failed: ${errorMsg}`,
@@ -417,7 +428,7 @@ export class PluginLoader extends EventEmitter<{
// Error isolation: set error state but don't crash
const errorMsg = err instanceof Error ? err.message : String(err);
await this.options.pluginStore.updatePluginState(
await this.updatePluginState(
pluginId,
"error",
errorMsg,
@@ -645,7 +656,7 @@ export class PluginLoader extends EventEmitter<{
);
// Update store state back to started
await this.options.pluginStore.updatePluginState(pluginId, "started");
await this.updatePluginState(pluginId, "started");
this.log.warn(`Rollback successful for ${pluginId}`);
} catch (rollbackErr) {
@@ -662,7 +673,7 @@ export class PluginLoader extends EventEmitter<{
const rollbackError = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
const combinedError = `Reload failed and rollback failed: ${originalError}; ${rollbackError}`;
await this.options.pluginStore.updatePluginState(
await this.updatePluginState(
pluginId,
"error",
combinedError,
@@ -871,8 +882,7 @@ export class PluginLoader extends EventEmitter<{
this.log.error(`Error in onUnload for ${pluginId}:`, err);
}
// Update state
await this.options.pluginStore.updatePluginState(pluginId, "stopped");
await this.updatePluginState(pluginId, "stopped");
// Remove from loaded plugins
this.plugins.delete(pluginId);
@@ -943,7 +953,7 @@ export class PluginLoader extends EventEmitter<{
// Update plugin state to error
try {
await this.options.pluginStore.updatePluginState(
await this.updatePluginState(
pluginId,
"error",
err instanceof Error ? err.message : String(err),

View File

@@ -33,8 +33,18 @@ DECLARE
BEGIN
SELECT rolsuper INTO current_user_is_superuser FROM pg_roles WHERE rolname = current_user;
IF current_user_is_superuser THEN
/*
FNXC:ProjectDataIsolation 2026-07-14-23:45:
PostgreSQL roles are cluster-wide while Gate databases apply this migration
concurrently. Advisory locks are database-local, so make CREATE ROLE itself
race-safe across databases by accepting the concurrent winner.
*/
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fusion_runtime') THEN
CREATE ROLE fusion_runtime NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
BEGIN
CREATE ROLE fusion_runtime NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
EXCEPTION
WHEN duplicate_object OR unique_violation THEN NULL;
END;
END IF;
EXECUTE format('GRANT fusion_runtime TO %I', current_user);
END IF;