fix: retry failed or blocked plugins on restart instead of leaving them disabled

This commit is contained in:
LukeGus committed 2026-10-06 23:08:10 -05:00
1 parent b6bb3ffc73
commit 27819c477c
3 files changed
+55 -13

No files matched your search

+16
View File
@@ -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<string> {
return new Set(
records
.filter((record) =>
["enabled", "failed", "blocked"].includes(record.state),
)
.map((record) => record.id),
);
}
+24 -13
View File
@@ -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<void> {
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<void> {
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.
}
@@ -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"]);
});
});