Simplify plugin runtime and cleanup lifecycle

This commit is contained in:
Dotta
2026-03-13 16:58:29 -05:00
parent 80cdbdbd47
commit 12ccfc2c9a
21 changed files with 120 additions and 838 deletions

View File

@@ -22,7 +22,7 @@ import path from "node:path";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { Router } from "express";
import type { Request, Response } from "express";
import type { Request } from "express";
import { and, desc, eq, gte } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companies, pluginLogs, pluginWebhookDeliveries } from "@paperclipai/db";
@@ -31,11 +31,9 @@ import type {
PaperclipPluginManifestV1,
PluginBridgeErrorCode,
PluginLauncherRenderContextSnapshot,
UpdateCompanyPluginAvailability,
} from "@paperclipai/shared";
import {
PLUGIN_STATUSES,
updateCompanyPluginAvailabilitySchema,
} from "@paperclipai/shared";
import { pluginRegistryService } from "../services/plugin-registry.js";
import { pluginLifecycleManager } from "../services/plugin-lifecycle.js";
@@ -186,15 +184,6 @@ async function resolvePlugin(
return registry.getByKey(pluginId);
}
async function isPluginAvailableForCompany(
registry: ReturnType<typeof pluginRegistryService>,
companyId: string,
pluginId: string,
): Promise<boolean> {
const availability = await registry.getCompanyAvailability(companyId, pluginId);
return availability?.available === true;
}
/**
* Optional dependencies for plugin job scheduling routes.
*
@@ -284,9 +273,6 @@ interface PluginToolExecuteRequest {
* | GET | /plugins/:pluginId/config | Get current plugin config |
* | POST | /plugins/:pluginId/config | Save (upsert) plugin config |
* | POST | /plugins/:pluginId/config/test | Test config via validateConfig RPC |
* | GET | /companies/:companyId/plugins | List company-scoped plugin availability |
* | GET | /companies/:companyId/plugins/:pluginId | Get company-scoped plugin availability |
* | PUT | /companies/:companyId/plugins/:pluginId | Save company-scoped plugin availability/settings |
* | POST | /plugins/:pluginId/bridge/data | Proxy getData to plugin worker |
* | POST | /plugins/:pluginId/bridge/action | Proxy performAction to plugin worker |
* | POST | /plugins/:pluginId/data/:key | Proxy getData to plugin worker (key in URL) |
@@ -420,10 +406,6 @@ export function pluginRoutes(
* - Slots are extracted from manifest.ui.slots
* - Launchers are aggregated from legacy manifest.launchers and manifest.ui.launchers
*
* Query params:
* - `companyId` (optional): filters out plugins disabled for the target
* company and applies `assertCompanyAccess`
*
* Example response:
* ```json
* [
@@ -451,21 +433,9 @@ export function pluginRoutes(
*/
router.get("/plugins/ui-contributions", async (req, res) => {
assertBoard(req);
const companyId = typeof req.query.companyId === "string" ? req.query.companyId : undefined;
if (companyId) {
assertCompanyAccess(req, companyId);
}
const plugins = await registry.listByStatus("ready");
const availablePluginIds = companyId
? new Set(
(await registry.listCompanyAvailability(companyId, { available: true }))
.map((entry) => entry.pluginId),
)
: null;
const contributions: PluginUiContribution[] = plugins
.filter((plugin) => availablePluginIds === null || availablePluginIds.has(plugin.id))
.map((plugin) => {
// Safety check: manifestJson should always exist for ready plugins, but guard against null
const manifest = plugin.manifestJson;
@@ -489,121 +459,6 @@ export function pluginRoutes(
res.json(contributions);
});
// ===========================================================================
// Company-scoped plugin settings / availability routes
// ===========================================================================
/**
* GET /api/companies/:companyId/plugins
*
* List every installed plugin as it applies to a specific company. Plugins
* are enabled by default; rows in `plugin_company_settings` only store
* explicit overrides and any company-scoped settings payload.
*
* Query params:
* - `available` (optional): `true` or `false` filter
*/
router.get("/companies/:companyId/plugins", async (req, res) => {
assertBoard(req);
const { companyId } = req.params;
assertCompanyAccess(req, companyId);
let available: boolean | undefined;
const rawAvailable = req.query.available;
if (rawAvailable !== undefined) {
if (rawAvailable === "true") available = true;
else if (rawAvailable === "false") available = false;
else {
res.status(400).json({ error: '"available" must be "true" or "false"' });
return;
}
}
const result = await registry.listCompanyAvailability(companyId, { available });
res.json(result);
});
/**
* GET /api/companies/:companyId/plugins/:pluginId
*
* Resolve one plugin's effective availability for a company, whether that
* result comes from the default-enabled baseline or a persisted override row.
*/
router.get("/companies/:companyId/plugins/:pluginId", async (req, res) => {
assertBoard(req);
const { companyId, pluginId } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin || plugin.status === "uninstalled") {
res.status(404).json({ error: "Plugin not found" });
return;
}
const result = await registry.getCompanyAvailability(companyId, plugin.id);
if (!result) {
res.status(404).json({ error: "Plugin not found" });
return;
}
res.json(result);
});
/**
* PUT /api/companies/:companyId/plugins/:pluginId
*
* Persist a company-scoped availability override. This never changes the
* instance-wide install state of the plugin; it only controls whether the
* selected company can see UI contributions and invoke plugin-backed actions.
*/
router.put("/companies/:companyId/plugins/:pluginId", async (req, res) => {
assertBoard(req);
const { companyId, pluginId } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin || plugin.status === "uninstalled") {
res.status(404).json({ error: "Plugin not found" });
return;
}
const parsed = updateCompanyPluginAvailabilitySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Invalid request body" });
return;
}
try {
const result = await registry.updateCompanyAvailability(
companyId,
plugin.id,
parsed.data as UpdateCompanyPluginAvailability,
);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "plugin.company_settings.updated",
entityType: "plugin_company_settings",
entityId: `${companyId}:${plugin.id}`,
details: {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
available: result.available,
settingsJson: result.settingsJson,
lastError: result.lastError,
},
});
res.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
res.status(400).json({ error: message });
}
});
// ===========================================================================
// Tool discovery and execution routes
// ===========================================================================
@@ -628,45 +483,11 @@ export function pluginRoutes(
}
const pluginId = req.query.pluginId as string | undefined;
const companyId = typeof req.query.companyId === "string" ? req.query.companyId : undefined;
if (companyId) {
assertCompanyAccess(req, companyId);
}
const filter = pluginId ? { pluginId } : undefined;
const tools = toolDeps.toolDispatcher.listToolsForAgent(filter);
if (!companyId) {
res.json(tools);
return;
}
const availablePluginIds = new Set(
(await registry.listCompanyAvailability(companyId, { available: true }))
.map((entry) => entry.pluginId),
);
res.json(tools.filter((tool) => availablePluginIds.has(tool.pluginId)));
res.json(tools);
});
/**
* Reject company-scoped plugin access when the plugin is disabled for the
* target company. This guard is reused across UI bridge and tool execution
* endpoints so every runtime surface honors the same availability rule.
*/
async function enforceCompanyPluginAvailability(
companyId: string,
pluginId: string,
res: Response,
): Promise<boolean> {
if (!await isPluginAvailableForCompany(registry, companyId, pluginId)) {
res.status(403).json({
error: `Plugin "${pluginId}" is not enabled for company "${companyId}"`,
});
return false;
}
return true;
}
/**
* POST /api/plugins/tools/execute
*
@@ -730,10 +551,6 @@ export function pluginRoutes(
return;
}
if (!await enforceCompanyPluginAvailability(runContext.companyId, registeredTool.pluginDbId, res)) {
return;
}
try {
const result = await toolDeps.toolDispatcher.executeTool(
tool,
@@ -824,13 +641,6 @@ export function pluginRoutes(
const existingPlugin = await registry.getByKey(discovered.manifest.id);
if (existingPlugin) {
await lifecycle.load(existingPlugin.id);
// Plugins should be enabled by default for all companies after install.
// Best-effort: default behavior is still enabled when no row exists.
try {
await registry.seedEnabledForAllCompanies(existingPlugin.id);
} catch {
// no-op
}
const updated = await registry.getById(existingPlugin.id);
await logPluginMutationActivity(req, "plugin.installed", existingPlugin.id, {
pluginId: existingPlugin.id,
@@ -859,7 +669,7 @@ export function pluginRoutes(
interface PluginBridgeDataRequest {
/** Plugin-defined data key (e.g. `"sync-health"`). */
key: string;
/** Optional company scope for enforcing company plugin availability. */
/** Optional company scope for authorizing company-context bridge calls. */
companyId?: string;
/** Optional context and query parameters from the UI. */
params?: Record<string, unknown>;
@@ -871,7 +681,7 @@ export function pluginRoutes(
interface PluginBridgeActionRequest {
/** Plugin-defined action key (e.g. `"resync"`). */
key: string;
/** Optional company scope for enforcing company plugin availability. */
/** Optional company scope for authorizing company-context bridge calls. */
companyId?: string;
/** Optional parameters from the UI. */
params?: Record<string, unknown>;
@@ -1010,9 +820,6 @@ export function pluginRoutes(
if (body.companyId) {
assertCompanyAccess(req, body.companyId);
if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) {
return;
}
}
try {
@@ -1096,9 +903,6 @@ export function pluginRoutes(
if (body.companyId) {
assertCompanyAccess(req, body.companyId);
if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) {
return;
}
}
try {
@@ -1182,9 +986,6 @@ export function pluginRoutes(
if (body?.companyId) {
assertCompanyAccess(req, body.companyId);
if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) {
return;
}
}
try {
@@ -1264,9 +1065,6 @@ export function pluginRoutes(
if (body?.companyId) {
assertCompanyAccess(req, body.companyId);
if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) {
return;
}
}
try {
@@ -1337,10 +1135,6 @@ export function pluginRoutes(
assertCompanyAccess(req, companyId);
if (!await enforceCompanyPluginAvailability(companyId, plugin.id, res)) {
return;
}
// Set SSE headers
res.writeHead(200, {
"Content-Type": "text/event-stream",