feat: collapse plugin runtime to one tier behind a plugin SDK

This commit is contained in:
LukeGus committed 2026-09-22 14:11:50 -05:00
1 parent 6e058f6e9e
commit 4758016f41
96 files changed
+34631 -8551

No files matched your search

+154 -44
View File
@@ -31,61 +31,88 @@ const requireManagePlugins = permissionManager.requirePermission(
* summary: List installed plugins and their runtime state
* description: >
* Returns every plugin known to the database, merged with the loader's
* live state so the UI can tell "enabled but crashed" from "disabled".
* live state so the UI can tell "enabled but failed" from "disabled".
* Open to any authenticated user, not just admins: the app shell calls
* this on every session to decide which plugin-contributed tabs and
* rail items to register, so gating it behind admin.plugins.manage
* would break the shell for non-admin users. Only the mutating routes
* below (enable/disable, grant/revoke) require that permission.
* would break the shell for non-admin users.
*
* Non-admins get only what the shell needs. What a plugin is allowed to
* do, what has been granted to it and why it failed are operational
* details, so capabilities, grants and lastError are included only for
* holders of admin.plugins.manage.
* tags:
* - Plugins
* responses:
* 200:
* description: List of plugins.
*/
router.get("/", authenticateJWT, async (_req: Request, res: Response) => {
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId as string;
const records = await createCurrentPluginRepository().listAll();
const { loader } = getPluginRuntime();
const live = new Map(loader.list().map((p) => [p.id, p]));
const grantRepository = createCurrentPluginPermissionGrantRepository();
const plugins = await Promise.all(
records.map(async (record) => {
const loaded = live.get(record.id);
let contributes: unknown = null;
let permissions: string[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
permissions?: unknown;
};
contributes = manifest?.contributes ?? null;
permissions = Array.isArray(manifest?.permissions)
? (manifest.permissions as string[])
: [];
} catch {
contributes = null;
}
const grants = await grantRepository.listByPlugin(record.id);
return {
id: record.id,
name: record.name,
version: record.version,
tier: record.tier,
source: record.source,
enabled: record.state === "enabled",
runtimeState: loaded?.state ?? "stopped",
lastError: loaded?.lastError ?? null,
contributes,
permissions,
grantedCapabilities: grants.map((grant) => grant.capability),
};
}),
const canManage = await permissionManager.hasPermission(
userId,
"admin.plugins.manage",
);
// One query for every plugin rather than one per plugin.
const grantsByPlugin = new Map<string, string[]>();
if (canManage) {
const grantRepository = createCurrentPluginPermissionGrantRepository();
await Promise.all(
records.map(async (record) => {
const grants = await grantRepository.listByPlugin(record.id);
grantsByPlugin.set(
record.id,
grants.map((grant) => grant.capability),
);
}),
);
}
const plugins = records.map((record) => {
const loaded = live.get(record.id);
let contributes: unknown = null;
let capabilities: string[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
capabilities?: unknown;
};
contributes = manifest?.contributes ?? null;
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
} catch {
contributes = null;
}
const summary = {
id: record.id,
name: record.name,
version: record.version,
enabled: record.state === "enabled",
state: loaded?.state ?? record.state,
contributes,
};
if (!canManage) return summary;
return {
...summary,
tier: record.tier,
source: record.source,
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
lastError: loaded?.lastError ?? record.lastError ?? null,
};
});
res.json(plugins);
} catch (error) {
databaseLogger.error(
@@ -135,7 +162,6 @@ router.patch(
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const pluginId = String(req.params.id);
const { enabled } = req.body ?? {};
@@ -157,12 +183,13 @@ router.patch(
// acts on it rather than silently reverting.
await repository.update(pluginId, {
state: enabled ? "enabled" : "disabled",
lastError: null,
});
const { activatePlugin, deactivatePlugin } =
await import("../../plugins/index.js");
if (enabled) await activatePlugin(pluginId, userId);
if (enabled) await activatePlugin(pluginId);
else await deactivatePlugin(pluginId);
databaseLogger.info(
@@ -182,6 +209,89 @@ router.patch(
},
);
/**
* @openapi
* /plugins/{id}/retry:
* post:
* summary: Start a plugin again after it failed
* description: >
* Clears the plugin's error budget and activates it. Use after fixing
* whatever made it fail, such as installing a missing dependency or
* freeing a port it needs.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The plugin's state after the attempt.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin, or it is not on disk.
* 500:
* description: The plugin failed to start again.
*/
router.post(
"/:id/retry",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
try {
const repository = createCurrentPluginRepository();
const record = await repository.findById(pluginId);
if (!record) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const { loader } = getPluginRuntime();
if (!loader.get(pluginId)) {
res.status(404).json({ error: "Plugin is not present on disk" });
return;
}
await repository.update(pluginId, {
state: "enabled",
lastError: null,
});
await loader.retry(pluginId);
databaseLogger.info(`Plugin ${pluginId} restarted`, {
operation: "plugin_retry",
pluginId,
});
res.json({ id: pluginId, state: loader.get(pluginId)?.state });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
databaseLogger.error(
`Failed to retry plugin ${pluginId}`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_retry" },
);
try {
await createCurrentPluginRepository().update(pluginId, {
state: "failed",
lastError: message,
});
} catch {
// Reporting the failure must not mask it.
}
res.status(500).json({ error: "Failed to start the plugin" });
}
},
);
/**
* @openapi
* /plugins/{id}/grants:
@@ -244,10 +354,10 @@ router.post(
let declared: string[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
permissions?: unknown;
capabilities?: unknown;
};
declared = Array.isArray(manifest?.permissions)
? (manifest.permissions as string[])
declared = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
} catch {
declared = [];