Simplify plugin runtime and cleanup lifecycle
This commit is contained in:
@@ -140,14 +140,7 @@ export async function createApp(
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
const workerManager = createPluginWorkerManager();
|
||||
const pluginRegistry = pluginRegistryService(db);
|
||||
const eventBus = createPluginEventBus({
|
||||
async isPluginEnabledForCompany(pluginKey, companyId) {
|
||||
const plugin = await pluginRegistry.getByKey(pluginKey);
|
||||
if (!plugin) return false;
|
||||
const availability = await pluginRegistry.getCompanyAvailability(companyId, plugin.id);
|
||||
return availability?.available ?? true;
|
||||
},
|
||||
});
|
||||
const eventBus = createPluginEventBus();
|
||||
const jobStore = pluginJobStore(db);
|
||||
const lifecycle = pluginLifecycleManager(db, { workerManager });
|
||||
const scheduler = createPluginJobScheduler({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -112,60 +112,6 @@ function passesFilter(event: PluginEvent, filter: EventFilter | null): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Company availability checker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Callback that checks whether a plugin is enabled for a given company.
|
||||
*
|
||||
* The event bus calls this during `emit()` to enforce company-scoped delivery:
|
||||
* events are only delivered to a plugin if the plugin is enabled for the
|
||||
* company that owns the event.
|
||||
*
|
||||
* Implementations should be fast — the bus caches results internally with a
|
||||
* short TTL so the checker is not invoked on every single event.
|
||||
*
|
||||
* @param pluginKey The plugin registry key — the string passed to `forPlugin()`
|
||||
* (e.g. `"acme.linear"`). This is the same key used throughout the bus
|
||||
* internally and should not be confused with a numeric or UUID plugin ID.
|
||||
* @param companyId UUID of the company to check availability for.
|
||||
*
|
||||
* Return `true` if the plugin is enabled (or if no settings row exists, i.e.
|
||||
* default-enabled), `false` if the company has explicitly disabled the plugin.
|
||||
*/
|
||||
export type CompanyAvailabilityChecker = (
|
||||
pluginKey: string,
|
||||
companyId: string,
|
||||
) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Options for {@link createPluginEventBus}.
|
||||
*/
|
||||
export interface PluginEventBusOptions {
|
||||
/**
|
||||
* Optional checker that gates event delivery per company.
|
||||
*
|
||||
* When provided, the bus will skip delivery to a plugin if the checker
|
||||
* returns `false` for the `(pluginKey, event.companyId)` pair, where
|
||||
* `pluginKey` is the registry key supplied to `forPlugin()`. Results are
|
||||
* cached with a short TTL (30 s) to avoid excessive lookups.
|
||||
*
|
||||
* When omitted, no company-scoping is applied (useful in tests).
|
||||
*/
|
||||
isPluginEnabledForCompany?: CompanyAvailabilityChecker;
|
||||
}
|
||||
|
||||
// Default cache TTL in milliseconds (30 seconds).
|
||||
const AVAILABILITY_CACHE_TTL_MS = 30_000;
|
||||
|
||||
// Maximum number of entries in the availability cache before it is cleared.
|
||||
// Prevents unbounded memory growth in long-running processes with many unique
|
||||
// (pluginKey, companyId) pairs. A full clear is intentionally simple — the
|
||||
// cache is advisory (performance only) and a miss merely triggers one extra
|
||||
// async lookup.
|
||||
const MAX_AVAILABILITY_CACHE_SIZE = 10_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event bus factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -200,40 +146,10 @@ const MAX_AVAILABILITY_CACHE_SIZE = 10_000;
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createPluginEventBus(options?: PluginEventBusOptions): PluginEventBus {
|
||||
const checker = options?.isPluginEnabledForCompany ?? null;
|
||||
|
||||
export function createPluginEventBus(): PluginEventBus {
|
||||
// Subscription registry: pluginKey → list of subscriptions
|
||||
const registry = new Map<string, Subscription[]>();
|
||||
|
||||
// Short-TTL cache for company availability lookups: "pluginKey\0companyId" → { enabled, expiresAt }
|
||||
const availabilityCache = new Map<string, { enabled: boolean; expiresAt: number }>();
|
||||
|
||||
function cacheKey(pluginKey: string, companyId: string): string {
|
||||
return `${pluginKey}\0${companyId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a plugin is enabled for a company, using the cached result
|
||||
* when available and falling back to the injected checker.
|
||||
*/
|
||||
async function isEnabledForCompany(pluginKey: string, companyId: string): Promise<boolean> {
|
||||
if (!checker) return true;
|
||||
|
||||
const key = cacheKey(pluginKey, companyId);
|
||||
const cached = availabilityCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.enabled;
|
||||
}
|
||||
|
||||
const enabled = await checker(pluginKey, companyId);
|
||||
if (availabilityCache.size >= MAX_AVAILABILITY_CACHE_SIZE) {
|
||||
availabilityCache.clear();
|
||||
}
|
||||
availabilityCache.set(key, { enabled, expiresAt: Date.now() + AVAILABILITY_CACHE_TTL_MS });
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve or create the subscription list for a plugin.
|
||||
*/
|
||||
@@ -257,26 +173,7 @@ export function createPluginEventBus(options?: PluginEventBusOptions): PluginEve
|
||||
const errors: Array<{ pluginId: string; error: unknown }> = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
// Pre-compute company availability for all registered plugins when the
|
||||
// event carries a companyId and a checker is configured. This batches
|
||||
// the (potentially async) lookups so we don't interleave them with
|
||||
// handler dispatch.
|
||||
let disabledPlugins: Set<string> | null = null;
|
||||
if (checker && event.companyId) {
|
||||
const pluginKeys = Array.from(registry.keys());
|
||||
const checks = await Promise.all(
|
||||
pluginKeys.map(async (pluginKey) => ({
|
||||
pluginKey,
|
||||
enabled: await isEnabledForCompany(pluginKey, event.companyId!),
|
||||
})),
|
||||
);
|
||||
disabledPlugins = new Set(checks.filter((c) => !c.enabled).map((c) => c.pluginKey));
|
||||
}
|
||||
|
||||
for (const [pluginId, subs] of registry) {
|
||||
// Skip delivery to plugins that are disabled for this company.
|
||||
if (disabledPlugins?.has(pluginId)) continue;
|
||||
|
||||
for (const sub of subs) {
|
||||
if (!matchesPattern(event.eventType, sub.eventPattern)) continue;
|
||||
if (!passesFilter(event, sub.filter)) continue;
|
||||
|
||||
@@ -466,18 +466,11 @@ export function buildHostServices(
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify that this plugin is enabled for the given company.
|
||||
* Throws if the plugin is disabled or unavailable, preventing
|
||||
* worker-driven access to companies that have not opted in.
|
||||
* Plugins are instance-wide in the current runtime. Company IDs are still
|
||||
* required for company-scoped data access, but there is no per-company
|
||||
* availability gate to enforce here.
|
||||
*/
|
||||
const ensurePluginAvailableForCompany = async (companyId: string) => {
|
||||
const availability = await registry.getCompanyAvailability(companyId, pluginId);
|
||||
if (!availability || !availability.available) {
|
||||
throw new Error(
|
||||
`Plugin "${pluginKey}" is not enabled for company "${companyId}"`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const ensurePluginAvailableForCompany = async (_companyId: string) => {};
|
||||
|
||||
const inCompany = <T extends { companyId: string | null | undefined }>(
|
||||
record: T | null | undefined,
|
||||
@@ -656,14 +649,7 @@ export function buildHostServices(
|
||||
|
||||
companies: {
|
||||
async list(_params) {
|
||||
const allCompanies = (await companies.list()) as Company[];
|
||||
if (allCompanies.length === 0) return [];
|
||||
|
||||
// Batch query: fetch all company settings for this plugin in one query
|
||||
// instead of N+1 individual getCompanyAvailability() calls.
|
||||
const companyIds = allCompanies.map((c) => c.id);
|
||||
const disabledCompanyIds = await registry.getDisabledCompanyIds(companyIds, pluginId);
|
||||
return allCompanies.filter((c) => !disabledCompanyIds.has(c.id));
|
||||
return (await companies.list()) as Company[];
|
||||
},
|
||||
async get(params) {
|
||||
await ensurePluginAvailableForCompany(params.companyId);
|
||||
|
||||
@@ -431,6 +431,22 @@ export function pluginLifecycleManager(
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivatePluginRuntime(
|
||||
pluginId: string,
|
||||
pluginKey: string,
|
||||
): Promise<void> {
|
||||
const supportsRuntimeDeactivation =
|
||||
typeof pluginLoaderInstance.hasRuntimeServices === "function"
|
||||
&& typeof pluginLoaderInstance.unloadSingle === "function";
|
||||
|
||||
if (supportsRuntimeDeactivation && pluginLoaderInstance.hasRuntimeServices()) {
|
||||
await pluginLoaderInstance.unloadSingle(pluginId, pluginKey);
|
||||
return;
|
||||
}
|
||||
|
||||
await stopWorkerIfRunning(pluginId, pluginKey);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -504,8 +520,7 @@ export function pluginLifecycleManager(
|
||||
);
|
||||
}
|
||||
|
||||
// Stop the worker before transitioning state
|
||||
await stopWorkerIfRunning(pluginId, plugin.pluginKey);
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
const result = await transition(pluginId, "disabled", reason ?? null, plugin);
|
||||
emitDomain("plugin.disabled", {
|
||||
@@ -526,6 +541,7 @@ export function pluginLifecycleManager(
|
||||
// If already uninstalled and removeData, hard-delete
|
||||
if (plugin.status === "uninstalled") {
|
||||
if (removeData) {
|
||||
await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
|
||||
const deleted = await registry.uninstall(pluginId, true);
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey },
|
||||
@@ -544,8 +560,8 @@ export function pluginLifecycleManager(
|
||||
);
|
||||
}
|
||||
|
||||
// Stop the worker before uninstalling
|
||||
await stopWorkerIfRunning(pluginId, plugin.pluginKey);
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
|
||||
|
||||
// Perform the uninstall via registry (handles soft/hard delete)
|
||||
const result = await registry.uninstall(pluginId, removeData);
|
||||
@@ -577,7 +593,7 @@ export function pluginLifecycleManager(
|
||||
// continue running. The worker manager's auto-restart is disabled
|
||||
// because we are intentionally taking the plugin offline.
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
await stopWorkerIfRunning(pluginId, plugin.pluginKey);
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
const result = await transition(pluginId, "error", error, plugin);
|
||||
emitDomain("plugin.error", {
|
||||
@@ -590,9 +606,8 @@ export function pluginLifecycleManager(
|
||||
|
||||
// -- markUpgradePending -----------------------------------------------
|
||||
async markUpgradePending(pluginId: string): Promise<PluginRecord> {
|
||||
// Stop the worker while waiting for operator approval of new capabilities
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
await stopWorkerIfRunning(pluginId, plugin.pluginKey);
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
const result = await transition(pluginId, "upgrade_pending", null, plugin);
|
||||
emitDomain("plugin.upgrade_pending", {
|
||||
@@ -637,8 +652,7 @@ export function pluginLifecycleManager(
|
||||
"plugin lifecycle: upgrade requested",
|
||||
);
|
||||
|
||||
// Stop the current worker before upgrading on disk
|
||||
await stopWorkerIfRunning(pluginId, plugin.pluginKey);
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
// 1. Download and validate new package via loader
|
||||
const { oldManifest, newManifest, discovered } =
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* @see PLUGIN_SPEC.md §12 — Process Model
|
||||
*/
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { readdir, readFile, rm, stat } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -394,6 +394,14 @@ export interface PluginLoader {
|
||||
*/
|
||||
isSupportedApiVersion(apiVersion: number): boolean;
|
||||
|
||||
/**
|
||||
* Remove runtime-managed on-disk install artifacts for a plugin.
|
||||
*
|
||||
* This only cleans files under the managed local plugin directory. Local-path
|
||||
* source checkouts outside that directory are intentionally left alone.
|
||||
*/
|
||||
cleanupInstallArtifacts(plugin: PluginRecord): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the local plugin directory this loader is configured to use.
|
||||
*/
|
||||
@@ -1334,6 +1342,50 @@ export function pluginLoader(
|
||||
return manifestValidator.getSupportedVersions().includes(apiVersion);
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// cleanupInstallArtifacts
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async cleanupInstallArtifacts(plugin: PluginRecord): Promise<void> {
|
||||
const managedTargets = new Set<string>();
|
||||
const managedNodeModulesDir = resolveManagedInstallPackageDir(localPluginDir, plugin.packageName);
|
||||
const directManagedDir = path.join(localPluginDir, plugin.packageName);
|
||||
|
||||
managedTargets.add(managedNodeModulesDir);
|
||||
if (isPathInsideDir(directManagedDir, localPluginDir)) {
|
||||
managedTargets.add(directManagedDir);
|
||||
}
|
||||
if (plugin.packagePath && isPathInsideDir(plugin.packagePath, localPluginDir)) {
|
||||
managedTargets.add(path.resolve(plugin.packagePath));
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(localPluginDir, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
await execFileAsync(
|
||||
"npm",
|
||||
["uninstall", plugin.packageName, "--prefix", localPluginDir, "--ignore-scripts"],
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{
|
||||
pluginId: plugin.id,
|
||||
pluginKey: plugin.pluginKey,
|
||||
packageName: plugin.packageName,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"plugin-loader: npm uninstall failed during cleanup, falling back to direct removal",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of managedTargets) {
|
||||
if (!existsSync(target)) continue;
|
||||
await rm(target, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getLocalPluginDir
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1850,3 +1902,17 @@ function resolveWorkerEntrypoint(
|
||||
`${path.resolve(directDir, workerRelPath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveManagedInstallPackageDir(localPluginDir: string, packageName: string): string {
|
||||
if (packageName.startsWith("@")) {
|
||||
return path.join(localPluginDir, "node_modules", ...packageName.split("/"));
|
||||
}
|
||||
return path.join(localPluginDir, "node_modules", packageName);
|
||||
}
|
||||
|
||||
function isPathInsideDir(candidatePath: string, parentDir: string): boolean {
|
||||
const resolvedCandidate = path.resolve(candidatePath);
|
||||
const resolvedParent = path.resolve(parentDir);
|
||||
const relative = path.relative(resolvedParent, resolvedCandidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { asc, eq, ne, sql, and, inArray } from "drizzle-orm";
|
||||
import { asc, eq, ne, sql, and } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
plugins,
|
||||
companies,
|
||||
pluginConfig,
|
||||
pluginCompanySettings,
|
||||
pluginEntities,
|
||||
pluginJobs,
|
||||
pluginJobRuns,
|
||||
@@ -17,10 +15,6 @@ import type {
|
||||
UpdatePluginStatus,
|
||||
UpsertPluginConfig,
|
||||
PatchPluginConfig,
|
||||
PluginCompanySettings,
|
||||
CompanyPluginAvailability,
|
||||
UpsertPluginCompanySettings,
|
||||
UpdateCompanyPluginAvailability,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
@@ -92,54 +86,6 @@ export function pluginRegistryService(db: Db) {
|
||||
return (result[0]?.maxOrder ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the persisted company override row for a plugin, if one exists.
|
||||
*
|
||||
* Missing rows are meaningful: the company inherits the default-enabled
|
||||
* behavior and the caller should treat the plugin as available.
|
||||
*/
|
||||
async function getCompanySettingsRow(companyId: string, pluginId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(pluginCompanySettings)
|
||||
.where(and(
|
||||
eq(pluginCompanySettings.companyId, companyId),
|
||||
eq(pluginCompanySettings.pluginId, pluginId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize registry records into the API response returned by company
|
||||
* plugin availability routes.
|
||||
*
|
||||
* The key business rule is captured here: plugins are enabled for a company
|
||||
* unless an explicit `plugin_company_settings.enabled = false` override says
|
||||
* otherwise.
|
||||
*/
|
||||
function toCompanyAvailability(
|
||||
companyId: string,
|
||||
plugin: Awaited<ReturnType<typeof getById>>,
|
||||
settings: PluginCompanySettings | null,
|
||||
): CompanyPluginAvailability {
|
||||
if (!plugin) {
|
||||
throw notFound("Plugin not found");
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
pluginId: plugin.id,
|
||||
pluginKey: plugin.pluginKey,
|
||||
pluginDisplayName: plugin.manifestJson.displayName,
|
||||
pluginStatus: plugin.status,
|
||||
available: settings?.enabled ?? true,
|
||||
settingsJson: settings?.settingsJson ?? {},
|
||||
lastError: settings?.lastError ?? null,
|
||||
createdAt: settings?.createdAt ?? null,
|
||||
updatedAt: settings?.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -416,233 +362,6 @@ export function pluginRegistryService(db: Db) {
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
// ----- Company-scoped settings ----------------------------------------
|
||||
|
||||
/** Retrieve a plugin's company-scoped settings row, if any. */
|
||||
getCompanySettings: (companyId: string, pluginId: string) =>
|
||||
getCompanySettingsRow(companyId, pluginId),
|
||||
|
||||
/** Create or replace the company-scoped settings row for a plugin. */
|
||||
upsertCompanySettings: async (
|
||||
companyId: string,
|
||||
pluginId: string,
|
||||
input: UpsertPluginCompanySettings,
|
||||
) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const existing = await getCompanySettingsRow(companyId, pluginId);
|
||||
if (existing) {
|
||||
return db
|
||||
.update(pluginCompanySettings)
|
||||
.set({
|
||||
enabled: true,
|
||||
settingsJson: input.settingsJson ?? {},
|
||||
lastError: input.lastError ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginCompanySettings.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(pluginCompanySettings)
|
||||
.values({
|
||||
companyId,
|
||||
pluginId,
|
||||
enabled: true,
|
||||
settingsJson: input.settingsJson ?? {},
|
||||
lastError: input.lastError ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/** Delete the company-scoped settings row for a plugin if it exists. */
|
||||
deleteCompanySettings: async (companyId: string, pluginId: string) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const existing = await getCompanySettingsRow(companyId, pluginId);
|
||||
if (!existing) return null;
|
||||
|
||||
return db
|
||||
.delete(pluginCompanySettings)
|
||||
.where(eq(pluginCompanySettings.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
/** List normalized company-plugin availability records across installed plugins. */
|
||||
listCompanyAvailability: async (
|
||||
companyId: string,
|
||||
filter?: { available?: boolean },
|
||||
) => {
|
||||
const installed = await db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(ne(plugins.status, "uninstalled"))
|
||||
.orderBy(asc(plugins.installOrder));
|
||||
|
||||
const settingsRows = await db
|
||||
.select()
|
||||
.from(pluginCompanySettings)
|
||||
.where(eq(pluginCompanySettings.companyId, companyId));
|
||||
|
||||
const settingsByPluginId = new Map(settingsRows.map((row) => [row.pluginId, row]));
|
||||
const availability = installed.map((plugin) => {
|
||||
const row = settingsByPluginId.get(plugin.id) ?? null;
|
||||
return {
|
||||
...toCompanyAvailability(companyId, plugin, row),
|
||||
};
|
||||
});
|
||||
|
||||
if (filter?.available === undefined) return availability;
|
||||
return availability.filter((item) => item.available === filter.available);
|
||||
},
|
||||
|
||||
/**
|
||||
* Batch-check which companies have this plugin explicitly disabled.
|
||||
* Returns a Set of companyIds where `enabled = false`. Companies with
|
||||
* no settings row default to enabled, so they are NOT in the result set.
|
||||
*/
|
||||
getDisabledCompanyIds: async (companyIds: string[], pluginId: string): Promise<Set<string>> => {
|
||||
if (companyIds.length === 0) return new Set();
|
||||
const rows = await db
|
||||
.select({
|
||||
companyId: pluginCompanySettings.companyId,
|
||||
enabled: pluginCompanySettings.enabled,
|
||||
})
|
||||
.from(pluginCompanySettings)
|
||||
.where(and(
|
||||
inArray(pluginCompanySettings.companyId, companyIds),
|
||||
eq(pluginCompanySettings.pluginId, pluginId),
|
||||
));
|
||||
const disabled = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.enabled) disabled.add(row.companyId);
|
||||
}
|
||||
return disabled;
|
||||
},
|
||||
|
||||
/** Get the normalized availability record for a single company/plugin pair. */
|
||||
getCompanyAvailability: async (companyId: string, pluginId: string) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin || plugin.status === "uninstalled") return null;
|
||||
|
||||
const settings = await getCompanySettingsRow(companyId, pluginId);
|
||||
return toCompanyAvailability(companyId, plugin, settings);
|
||||
},
|
||||
|
||||
/** Update normalized company availability, persisting or deleting settings as needed. */
|
||||
updateCompanyAvailability: async (
|
||||
companyId: string,
|
||||
pluginId: string,
|
||||
input: UpdateCompanyPluginAvailability,
|
||||
) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin || plugin.status === "uninstalled") throw notFound("Plugin not found");
|
||||
|
||||
const existing = await getCompanySettingsRow(companyId, pluginId);
|
||||
|
||||
if (!input.available) {
|
||||
const row = await (existing
|
||||
? db
|
||||
.update(pluginCompanySettings)
|
||||
.set({
|
||||
enabled: false,
|
||||
settingsJson: input.settingsJson ?? existing.settingsJson,
|
||||
lastError: input.lastError ?? existing.lastError ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginCompanySettings.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0])
|
||||
: db
|
||||
.insert(pluginCompanySettings)
|
||||
.values({
|
||||
companyId,
|
||||
pluginId,
|
||||
enabled: false,
|
||||
settingsJson: input.settingsJson ?? {},
|
||||
lastError: input.lastError ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]));
|
||||
|
||||
return {
|
||||
...toCompanyAvailability(companyId, plugin, row),
|
||||
};
|
||||
}
|
||||
|
||||
const row = await (existing
|
||||
? db
|
||||
.update(pluginCompanySettings)
|
||||
.set({
|
||||
enabled: true,
|
||||
settingsJson: input.settingsJson ?? existing.settingsJson,
|
||||
lastError: input.lastError ?? existing.lastError ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginCompanySettings.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0])
|
||||
: db
|
||||
.insert(pluginCompanySettings)
|
||||
.values({
|
||||
companyId,
|
||||
pluginId,
|
||||
enabled: true,
|
||||
settingsJson: input.settingsJson ?? {},
|
||||
lastError: input.lastError ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]));
|
||||
|
||||
return {
|
||||
...toCompanyAvailability(companyId, plugin, row),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure all companies have an explicit enabled row for this plugin.
|
||||
*
|
||||
* Company availability defaults to enabled when no row exists, but this
|
||||
* helper persists explicit `enabled=true` rows so newly-installed plugins
|
||||
* appear as enabled immediately and consistently in company-scoped views.
|
||||
*/
|
||||
seedEnabledForAllCompanies: async (pluginId: string) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin || plugin.status === "uninstalled") throw notFound("Plugin not found");
|
||||
|
||||
const companyRows = await db
|
||||
.select({ id: companies.id })
|
||||
.from(companies);
|
||||
|
||||
if (companyRows.length === 0) return 0;
|
||||
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(pluginCompanySettings)
|
||||
.values(
|
||||
companyRows.map((company) => ({
|
||||
companyId: company.id,
|
||||
pluginId,
|
||||
enabled: true,
|
||||
settingsJson: {},
|
||||
lastError: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: [pluginCompanySettings.companyId, pluginCompanySettings.pluginId],
|
||||
});
|
||||
|
||||
return companyRows.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Record an error against a plugin's config (e.g. validation failure
|
||||
* against the plugin's instanceConfigSchema).
|
||||
|
||||
@@ -321,19 +321,6 @@ export function createPluginSecretsHandler(
|
||||
throw secretNotFound(trimmedRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 2b. Verify the plugin is available for the secret's company.
|
||||
// This prevents cross-company secret access via UUID guessing.
|
||||
// ---------------------------------------------------------------
|
||||
const companyId = (secret as { companyId?: string }).companyId;
|
||||
if (companyId) {
|
||||
const availability = await registry.getCompanyAvailability(companyId, pluginId);
|
||||
if (!availability || !availability.available) {
|
||||
// Return the same error as "not found" to avoid leaking existence
|
||||
throw secretNotFound(trimmedRef);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 3. Fetch the latest version's material
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user