From be55d0a987498edd0e36f231e8adb2cfe4de351f Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Tue, 14 Jul 2026 18:20:31 -0700 Subject: [PATCH] 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 ## 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. --- .changeset/calm-skills-postgres.md | 8 +++ .../cli/src/commands/__tests__/daemon.test.ts | 33 ++++++++++- .../src/commands/__tests__/dashboard.test.ts | 57 ++++++++++++++++++- .../cli/src/commands/__tests__/serve.test.ts | 33 ++++++++++- packages/cli/src/commands/daemon.ts | 10 +++- packages/cli/src/commands/dashboard.ts | 40 ++++++++++++- packages/cli/src/commands/serve.ts | 10 +++- .../src/__tests__/plugin-hot-reload.test.ts | 15 +++++ packages/core/src/plugin-loader.ts | 28 ++++++--- .../migrations/0006_project_ownership.sql | 12 +++- 10 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 .changeset/calm-skills-postgres.md diff --git a/.changeset/calm-skills-postgres.md b/.changeset/calm-skills-postgres.md new file mode 100644 index 0000000000..a7aefe3abf --- /dev/null +++ b/.changeset/calm-skills-postgres.md @@ -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. diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index c6f0953874..7cea39de9b 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -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; + }; + 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 diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 149a0555ef..ab46e3d649 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -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): 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) => Promise } + | 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; + }; + expect(scopedPluginLoader.stopAllPlugins).toHaveBeenCalledWith(); + + dashboard.dispose(); + expect(scopedStore.close).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + describe("runDashboard — startup model sync", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index 154e9eb5f6..51caa16294 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -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; + }; + 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"); diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 4178a391e5..ebbdf0df68 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -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(); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 5c3a651233..977899c191 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -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(); + const projectStoreShutdowns = new Map Promise>(); + let projectStoresClosePromise: Promise | undefined; async function getProjectStore(projectPath: string): Promise { 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 { + 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> => { 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); }; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index d7e9f7d522..e42e94b544 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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(); diff --git a/packages/core/src/__tests__/plugin-hot-reload.test.ts b/packages/core/src/__tests__/plugin-hot-reload.test.ts index 894af1729e..e5ff5eac76 100644 --- a/packages/core/src/__tests__/plugin-hot-reload.test.ts +++ b/packages/core/src/__tests__/plugin-hot-reload.test.ts @@ -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", () => { diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index c06cc4db0c..b2595a99c9 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -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 { + 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), diff --git a/packages/core/src/postgres/migrations/0006_project_ownership.sql b/packages/core/src/postgres/migrations/0006_project_ownership.sql index d0070b7813..f4fd0f31c0 100644 --- a/packages/core/src/postgres/migrations/0006_project_ownership.sql +++ b/packages/core/src/postgres/migrations/0006_project_ownership.sql @@ -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;