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:
8
.changeset/calm-skills-postgres.md
Normal file
8
.changeset/calm-skills-postgres.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user