mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Handle update states and rollback failed plugin loads
This commit is contained in:
parent
bc872a29b3
commit
a8ccdf0403
5 changed files with 195 additions and 20 deletions
|
|
@ -1157,8 +1157,8 @@ async function writeCoreGatewayConfig(
|
|||
mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true });
|
||||
const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig();
|
||||
const providerPlugins = withCodexOauthRuntimeDefaults([
|
||||
...(config.providerPlugins ?? []),
|
||||
...pluginService.getCoreProviderPlugins()
|
||||
...(config.providerPlugins ?? []).filter(providerPluginEnabled),
|
||||
...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled)
|
||||
]);
|
||||
const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins);
|
||||
const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
|
||||
|
|
@ -1244,6 +1244,10 @@ function writePrivateTextFile(file: string, content: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
function providerPluginEnabled(plugin: unknown): boolean {
|
||||
return !isRecord(plugin) || plugin.enabled !== false;
|
||||
}
|
||||
|
||||
export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] {
|
||||
return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,18 @@ type LoadedPlugin = {
|
|||
stop?: () => MaybePromise<void>;
|
||||
};
|
||||
|
||||
type PluginServiceStateSnapshot = {
|
||||
apps: InstalledBrowserApp[];
|
||||
coreGatewayConfig: Record<string, unknown>;
|
||||
coreProviderPlugins: unknown[];
|
||||
gatewayRoutes: RegisteredGatewayRoute[];
|
||||
providerAccountConnectors: Map<string, GatewayPluginProviderAccountConnector>;
|
||||
proxyRoutes: RegisteredProxyRoute[];
|
||||
resourceOwnerIds: Set<string>;
|
||||
stopHooks: Array<() => MaybePromise<void>>;
|
||||
virtualModelProfiles: unknown[];
|
||||
};
|
||||
|
||||
const requireFromHere = createRequire(__filename);
|
||||
const builtInMarketplacePluginModules = new Map<string, string>([
|
||||
["claude-design", path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs")],
|
||||
|
|
@ -172,8 +184,14 @@ class GatewayPluginService {
|
|||
if (pluginConfig.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
const snapshot = this.createStateSnapshot();
|
||||
this.resourceOwnerIds.add(pluginConfig.id);
|
||||
await this.loadConfiguredPlugin(pluginConfig);
|
||||
try {
|
||||
await this.loadConfiguredPlugin(pluginConfig);
|
||||
} catch (error) {
|
||||
await this.rollbackConfiguredPluginLoad(pluginConfig.id, snapshot);
|
||||
console.warn(`[plugin:${pluginConfig.id}] Disabled after startup failure: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -513,6 +531,47 @@ class GatewayPluginService {
|
|||
): Promise<PluginSqliteStore> {
|
||||
return backendService.openSqliteStore(pluginId, pluginDataDir, options);
|
||||
}
|
||||
|
||||
private createStateSnapshot(): PluginServiceStateSnapshot {
|
||||
return {
|
||||
apps: [...this.apps],
|
||||
coreGatewayConfig: { ...this.coreGatewayConfig },
|
||||
coreProviderPlugins: [...this.coreProviderPlugins],
|
||||
gatewayRoutes: [...this.gatewayRoutes],
|
||||
providerAccountConnectors: new Map(this.providerAccountConnectors),
|
||||
proxyRoutes: [...this.proxyRoutes],
|
||||
resourceOwnerIds: new Set(this.resourceOwnerIds),
|
||||
stopHooks: [...this.stopHooks],
|
||||
virtualModelProfiles: [...this.virtualModelProfiles]
|
||||
};
|
||||
}
|
||||
|
||||
private async rollbackConfiguredPluginLoad(pluginId: string, snapshot: PluginServiceStateSnapshot): Promise<void> {
|
||||
const newStopHooks = this.stopHooks.slice(snapshot.stopHooks.length).reverse();
|
||||
this.apps = snapshot.apps;
|
||||
this.coreGatewayConfig = snapshot.coreGatewayConfig;
|
||||
this.coreProviderPlugins = snapshot.coreProviderPlugins;
|
||||
this.gatewayRoutes = snapshot.gatewayRoutes;
|
||||
this.providerAccountConnectors = snapshot.providerAccountConnectors;
|
||||
this.proxyRoutes = snapshot.proxyRoutes;
|
||||
this.resourceOwnerIds = snapshot.resourceOwnerIds;
|
||||
this.stopHooks = snapshot.stopHooks;
|
||||
this.virtualModelProfiles = snapshot.virtualModelProfiles;
|
||||
|
||||
for (const stopHook of newStopHooks) {
|
||||
try {
|
||||
await stopHook();
|
||||
} catch (error) {
|
||||
console.warn(`[plugin:${pluginId}] Rollback stop hook failed: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await backendService.stopOwner(pluginId);
|
||||
} catch (error) {
|
||||
console.warn(`[plugin:${pluginId}] Rollback resource cleanup failed: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginService = new GatewayPluginService();
|
||||
|
|
|
|||
|
|
@ -827,12 +827,23 @@ function App() {
|
|||
void checkForAppUpdate();
|
||||
}
|
||||
|
||||
function openUpdateDownloadDialog() {
|
||||
function openSidebarUpdateDialog() {
|
||||
setUpdateDialogOpen(true);
|
||||
setUpdateActionError("");
|
||||
if (updateDialogStatus.canDownload || updateDialogStatus.state === "available") {
|
||||
void downloadAppUpdate();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
updateDialogStatus.canInstall ||
|
||||
updateDialogStatus.state === "checking" ||
|
||||
updateDialogStatus.state === "downloading" ||
|
||||
updateDialogStatus.state === "downloaded" ||
|
||||
updateDialogStatus.state === "installing"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void checkForAppUpdate();
|
||||
}
|
||||
|
||||
async function checkForAppUpdate() {
|
||||
|
|
@ -2816,7 +2827,7 @@ function App() {
|
|||
isMac={isMac}
|
||||
needsTrafficLightSafeArea={needsTrafficLightSafeArea}
|
||||
networkCaptureEnabled={networkCaptureEnabled}
|
||||
onDownloadUpdate={openUpdateDownloadDialog}
|
||||
onOpenUpdate={openSidebarUpdateDialog}
|
||||
onOpenSettings={openSettingsDialog}
|
||||
onSelectNavigationItem={selectNavigationItem}
|
||||
onToggleSidebar={() => setSidebarOpen((current) => !current)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, cn, EndpointTitleBar,
|
||||
AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, Check, cn, EndpointTitleBar,
|
||||
AppUpdateStatus, Download, GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase,
|
||||
LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen,
|
||||
LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, RefreshCw,
|
||||
reducedMotionTransition, ServiceControlButton, Settings, ViewId,
|
||||
ViewMotionShell, viewUsesInternalScroll
|
||||
} from "../shared/index";
|
||||
|
|
@ -63,7 +63,7 @@ export function MainLayout({
|
|||
needsTrafficLightSafeArea,
|
||||
agentAnalysisEnabled,
|
||||
networkCaptureEnabled,
|
||||
onDownloadUpdate,
|
||||
onOpenUpdate,
|
||||
onOpenSettings,
|
||||
onSelectNavigationItem,
|
||||
onToggleSidebar,
|
||||
|
|
@ -86,7 +86,7 @@ export function MainLayout({
|
|||
isMac: boolean;
|
||||
needsTrafficLightSafeArea: boolean;
|
||||
networkCaptureEnabled: boolean;
|
||||
onDownloadUpdate: () => void;
|
||||
onOpenUpdate: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onSelectNavigationItem: (id: NavigationId) => void;
|
||||
onToggleSidebar: () => void;
|
||||
|
|
@ -99,15 +99,24 @@ export function MainLayout({
|
|||
requestLogsEnabled: boolean;
|
||||
visibleNavigation: MainNavigationItem[];
|
||||
}) {
|
||||
const windowControlSafeAreaWidth = isMac ? 152 : 88;
|
||||
const showUpdateDownloadButton =
|
||||
updateStatus.supported &&
|
||||
(updateStatus.state === "available" || updateStatus.state === "downloading" || updateStatus.state === "downloaded");
|
||||
const updateDownloadLabel = updateStatus.state === "downloaded"
|
||||
const showUpdateButton = updateStatus.supported;
|
||||
const windowControlSafeAreaWidth = showUpdateButton
|
||||
? (isMac ? 188 : 124)
|
||||
: (isMac ? 152 : 88);
|
||||
const updateBusy =
|
||||
updateActionBusy ||
|
||||
updateStatus.state === "checking" ||
|
||||
updateStatus.state === "downloading" ||
|
||||
updateStatus.state === "installing";
|
||||
const updateLabel = updateStatus.state === "downloaded"
|
||||
? copy.text["Update ready to install"] ?? "Update ready to install"
|
||||
: updateStatus.state === "downloading"
|
||||
? copy.text["Downloading update"] ?? "Downloading update"
|
||||
: copy.text["Download update"] ?? "Download update";
|
||||
: updateStatus.state === "checking"
|
||||
? copy.text["Checking for updates"] ?? "Checking for updates"
|
||||
: updateStatus.state === "available"
|
||||
? copy.text["Download update"] ?? "Download update"
|
||||
: copy.text["Check for updates"] ?? "Check for updates";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -132,17 +141,25 @@ export function MainLayout({
|
|||
onClick={toggleGatewayService}
|
||||
state={gatewayStatus.state}
|
||||
/>
|
||||
{showUpdateDownloadButton ? (
|
||||
{showUpdateButton ? (
|
||||
<Button
|
||||
aria-label={updateDownloadLabel}
|
||||
aria-label={updateLabel}
|
||||
className="app-no-drag inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent bg-transparent p-0 text-primary outline-none transition-colors hover:bg-primary/10 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring/25"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onDownloadUpdate}
|
||||
title={updateDownloadLabel}
|
||||
onClick={onOpenUpdate}
|
||||
title={updateLabel}
|
||||
type="button"
|
||||
unstyled
|
||||
>
|
||||
{updateActionBusy || updateStatus.state === "downloading" ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
|
||||
{updateBusy ? (
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
||||
) : updateStatus.state === "downloaded" ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : updateStatus.state === "available" ? (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
84
tests/main/plugin-service.test.mjs
Normal file
84
tests/main/plugin-service.test.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts";
|
||||
import { pluginService } from "../../packages/core/src/plugins/service.ts";
|
||||
|
||||
test("plugin service skips failed plugins and rolls back their registrations", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-plugin-service-test-"));
|
||||
const warnings = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args.map(String).join(" "));
|
||||
|
||||
try {
|
||||
const brokenPlugin = path.join(dir, "broken-plugin.cjs");
|
||||
const goodPlugin = path.join(dir, "good-plugin.cjs");
|
||||
writeFileSync(brokenPlugin, `
|
||||
module.exports = async function brokenPlugin(context) {
|
||||
context.registerApp({ id: "broken-context-app", name: "Broken context", url: "http://broken-context.local" });
|
||||
context.registerGatewayRoute({ id: "broken-context-route", path: "/broken-context", handler(_request, response) { response.end("broken"); } });
|
||||
context.registerCoreGatewayProviderPlugin({ key: "broken-context-provider" });
|
||||
context.registerCoreGatewayVirtualModelProfile({ id: "broken-context-vm" });
|
||||
context.registerProviderAccountConnector({ id: "broken-account", resolve() { return []; } });
|
||||
throw new Error("broken plugin setup failed");
|
||||
};
|
||||
`);
|
||||
writeFileSync(goodPlugin, `
|
||||
module.exports = {
|
||||
setup(context) {
|
||||
context.registerCoreGatewayProviderPlugin({ key: "good-context-provider" });
|
||||
context.registerCoreGatewayVirtualModelProfile({ id: "good-context-vm" });
|
||||
return {
|
||||
apps: [{ id: "good-app", name: "Good app", url: "http://good.local" }],
|
||||
coreGateway: {
|
||||
config: { agent: { mcpServers: [{ name: "good-mcp", command: "good" }] } },
|
||||
providerPlugins: [{ key: "good-provider" }],
|
||||
virtualModelProfiles: [{ id: "good-vm" }]
|
||||
},
|
||||
gatewayRoutes: [{ id: "good-route", path: "/good", handler(_request, response) { response.end("good"); } }],
|
||||
providerAccountConnectors: [{ id: "good-account", resolve() { return []; } }],
|
||||
proxyRoutes: [{ host: "good.local", upstream: "http://127.0.0.1" }]
|
||||
};
|
||||
}
|
||||
};
|
||||
`);
|
||||
|
||||
const config = createDefaultAppConfig({ generatedConfigFile: path.join(dir, "gateway.json") });
|
||||
config.plugins = [
|
||||
{
|
||||
apps: [{ id: "broken-config-app", name: "Broken config", url: "http://broken-config.local" }],
|
||||
coreGateway: {
|
||||
config: { agent: { mcpServers: [{ name: "broken-config-mcp", command: "broken" }] } },
|
||||
providerPlugins: [{ key: "broken-config-provider" }],
|
||||
virtualModelProfiles: [{ id: "broken-config-vm" }]
|
||||
},
|
||||
id: "broken",
|
||||
module: brokenPlugin,
|
||||
proxy: { routes: [{ host: "broken.local", upstream: "http://127.0.0.1" }] }
|
||||
},
|
||||
{
|
||||
id: "good",
|
||||
module: goodPlugin
|
||||
}
|
||||
];
|
||||
|
||||
await pluginService.start(config);
|
||||
|
||||
assert.match(warnings.join("\n"), /plugin:broken.*Disabled after startup failure.*broken plugin setup failed/);
|
||||
assert.deepEqual(pluginService.getApps().map((app) => app.id), ["good-app"]);
|
||||
assert.equal(pluginService.matchGatewayRoute("GET", "/broken-context"), undefined);
|
||||
assert.equal(pluginService.matchGatewayRoute("GET", "/good")?.id, "good-route");
|
||||
assert.deepEqual(pluginService.getProxyRouteTargets(), [{ host: "good.local", paths: undefined }]);
|
||||
assert.deepEqual(pluginService.getCoreProviderPlugins().map((plugin) => plugin.key), ["good-context-provider", "good-provider"]);
|
||||
assert.deepEqual(pluginService.getVirtualModelProfiles().map((profile) => profile.id), ["good-context-vm", "good-vm"]);
|
||||
assert.deepEqual(pluginService.getCoreGatewayConfig(), { agent: { mcpServers: [{ name: "good-mcp", command: "good" }] } });
|
||||
assert.equal(pluginService.getProviderAccountConnector("broken", "broken-account"), undefined);
|
||||
assert.equal(typeof pluginService.getProviderAccountConnector("good", "good-account")?.resolve, "function");
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
await pluginService.stop();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue