From 27819c477cdb999d8cd051e5f6c9e69a5938e581 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 6 Oct 2026 23:08:10 -0500 Subject: [PATCH] fix: retry failed or blocked plugins on restart instead of leaving them disabled --- src/backend/plugins/boot-state.ts | 16 +++++++++ src/backend/plugins/index.ts | 37 +++++++++++++------- src/backend/tests/plugins/boot-state.test.ts | 15 ++++++++ 3 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 src/backend/plugins/boot-state.ts create mode 100644 src/backend/tests/plugins/boot-state.test.ts diff --git a/src/backend/plugins/boot-state.ts b/src/backend/plugins/boot-state.ts new file mode 100644 index 000000000..334ef3f2e --- /dev/null +++ b/src/backend/plugins/boot-state.ts @@ -0,0 +1,16 @@ +/** + * Plugins to start at boot. One that failed or was blocked last run is still + * one the admin turned on, so a restart tries it again rather than leaving it + * looking disabled. + */ +export function startablePluginIds( + records: { id: string; state: string }[], +): Set { + return new Set( + records + .filter((record) => + ["enabled", "failed", "blocked"].includes(record.state), + ) + .map((record) => record.id), + ); +} diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index 76d6bacc5..a8dd5bead 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -22,6 +22,7 @@ import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-regist import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js"; import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js"; import { recordConflict } from "./conflicts.js"; +import { startablePluginIds } from "./boot-state.js"; import { setPluginImpersonationCheck } from "../utils/auth-manager.js"; import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js"; @@ -94,9 +95,9 @@ export function getPluginRuntime(): { loader: PluginLoader } { } /** - * A plugin that tripped the error budget is off until an admin retries it, so - * it has to look off everywhere: no routes, greyed permissions, and a failed - * row that survives a restart. + * A plugin that tripped the error budget is off until an admin retries it or + * the server restarts, so it has to look off everywhere: no routes and greyed + * permissions. */ async function handlePluginFailed(plugin: LoadedPlugin): Promise { unregisterPluginHttp(plugin.id); @@ -245,11 +246,7 @@ export async function initializePlugins( const { createCurrentPluginRepository } = await import("../database/repositories/factory.js"); const records = await createCurrentPluginRepository().listAll(); - const enabled = new Set( - records - .filter((record) => record.state === "enabled") - .map((record) => record.id), - ); + const enabled = startablePluginIds(records); const candidates = loaded .filter((plugin) => enabled.has(plugin.id)) @@ -278,14 +275,28 @@ async function persistRuntimeState(loaded: LoadedPlugin[]): Promise { const { createCurrentPluginRepository } = await import("../database/repositories/factory.js"); const repository = createCurrentPluginRepository(); + const records = new Map( + (await repository.listAll()).map((record) => [record.id, record]), + ); for (const plugin of loaded) { - if (plugin.state !== "blocked" && plugin.state !== "failed") continue; + const record = records.get(plugin.id); try { - await repository.update(plugin.id, { - state: plugin.state, - lastError: plugin.lastError, - }); + if (plugin.state === "blocked" || plugin.state === "failed") { + await repository.update(plugin.id, { + state: plugin.state, + lastError: plugin.lastError, + }); + } else if ( + plugin.state === "active" && + record && + record.state !== "enabled" + ) { + await repository.update(plugin.id, { + state: "enabled", + lastError: null, + }); + } } catch { // Reporting state must never stop the boot. } diff --git a/src/backend/tests/plugins/boot-state.test.ts b/src/backend/tests/plugins/boot-state.test.ts new file mode 100644 index 000000000..9b23d8081 --- /dev/null +++ b/src/backend/tests/plugins/boot-state.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { startablePluginIds } from "../../plugins/boot-state.js"; + +describe("startablePluginIds", () => { + it("starts enabled plugins and retries ones that failed or were blocked", () => { + const ids = startablePluginIds([ + { id: "a", state: "enabled" }, + { id: "b", state: "failed" }, + { id: "c", state: "blocked" }, + { id: "d", state: "disabled" }, + { id: "e", state: "pending" }, + ]); + expect([...ids].sort()).toEqual(["a", "b", "c"]); + }); +});