| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { existsSync } from "node:fs"; |
| import path from "node:path"; |
| import { randomUUID } from "node:crypto"; |
| import { fileURLToPath } from "node:url"; |
| import { Router } 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"; |
| import type { |
| PluginStatus, |
| PaperclipPluginManifestV1, |
| PluginBridgeErrorCode, |
| PluginLauncherRenderContextSnapshot, |
| } from "@paperclipai/shared"; |
| import { |
| PLUGIN_STATUSES, |
| } from "@paperclipai/shared"; |
| import { pluginRegistryService } from "../services/plugin-registry.js"; |
| import { pluginLifecycleManager } from "../services/plugin-lifecycle.js"; |
| import { getPluginUiContributionMetadata, pluginLoader } from "../services/plugin-loader.js"; |
| import { logActivity } from "../services/activity-log.js"; |
| import { publishGlobalLiveEvent } from "../services/live-events.js"; |
| import type { PluginJobScheduler } from "../services/plugin-job-scheduler.js"; |
| import type { PluginJobStore } from "../services/plugin-job-store.js"; |
| import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; |
| import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; |
| import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; |
| import type { ToolRunContext } from "@paperclipai/plugin-sdk"; |
| import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; |
| import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; |
| import { validateInstanceConfig } from "../services/plugin-config-validator.js"; |
|
|
| |
| type PluginUiSlotDeclaration = NonNullable<NonNullable<PaperclipPluginManifestV1["ui"]>["slots"]>[number]; |
| |
| type PluginLauncherDeclaration = NonNullable<PaperclipPluginManifestV1["launchers"]>[number]; |
|
|
| |
| |
| |
| |
| type PluginUiContribution = { |
| pluginId: string; |
| pluginKey: string; |
| displayName: string; |
| version: string; |
| updatedAt: string; |
| |
| |
| |
| |
| |
| uiEntryFile: string; |
| slots: PluginUiSlotDeclaration[]; |
| launchers: PluginLauncherDeclaration[]; |
| }; |
|
|
| |
| interface PluginInstallRequest { |
| |
| packageName: string; |
| |
| version?: string; |
| |
| isLocalPath?: boolean; |
| } |
|
|
| interface AvailablePluginExample { |
| packageName: string; |
| pluginKey: string; |
| displayName: string; |
| description: string; |
| localPath: string; |
| tag: "example"; |
| } |
|
|
| |
| interface PluginHealthCheckResult { |
| pluginId: string; |
| status: string; |
| healthy: boolean; |
| checks: Array<{ |
| name: string; |
| passed: boolean; |
| message?: string; |
| }>; |
| lastError?: string; |
| } |
|
|
| |
| const UUID_REGEX = |
| /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; |
|
|
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| const REPO_ROOT = path.resolve(__dirname, "../../.."); |
|
|
| const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [ |
| { |
| packageName: "@paperclipai/plugin-hello-world-example", |
| pluginKey: "paperclip.hello-world-example", |
| displayName: "Hello World Widget (Example)", |
| description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.", |
| localPath: "packages/plugins/examples/plugin-hello-world-example", |
| tag: "example", |
| }, |
| { |
| packageName: "@paperclipai/plugin-file-browser-example", |
| pluginKey: "paperclip-file-browser-example", |
| displayName: "File Browser (Example)", |
| description: "Example plugin that adds a Files link in project navigation plus a project detail file browser.", |
| localPath: "packages/plugins/examples/plugin-file-browser-example", |
| tag: "example", |
| }, |
| { |
| packageName: "@paperclipai/plugin-kitchen-sink-example", |
| pluginKey: "paperclip-kitchen-sink-example", |
| displayName: "Kitchen Sink (Example)", |
| description: "Reference plugin that demonstrates the current Paperclip plugin API surface, bridge flows, UI extension surfaces, jobs, webhooks, tools, streams, and trusted local workspace/process demos.", |
| localPath: "packages/plugins/examples/plugin-kitchen-sink-example", |
| tag: "example", |
| }, |
| ]; |
|
|
| function listBundledPluginExamples(): AvailablePluginExample[] { |
| return BUNDLED_PLUGIN_EXAMPLES.flatMap((plugin) => { |
| const absoluteLocalPath = path.resolve(REPO_ROOT, plugin.localPath); |
| if (!existsSync(absoluteLocalPath)) return []; |
| return [{ ...plugin, localPath: absoluteLocalPath }]; |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function resolvePlugin( |
| registry: ReturnType<typeof pluginRegistryService>, |
| pluginId: string, |
| ) { |
| const isUuid = UUID_REGEX.test(pluginId); |
| const isScopedPackageKey = pluginId.startsWith("@") || pluginId.includes("/"); |
|
|
| |
| |
| if (isScopedPackageKey && !isUuid) { |
| return registry.getByKey(pluginId); |
| } |
|
|
| try { |
| const byId = await registry.getById(pluginId); |
| if (byId) return byId; |
| } catch (error) { |
| const maybeCode = |
| typeof error === "object" && error !== null && "code" in error |
| ? (error as { code?: unknown }).code |
| : undefined; |
| |
| if (maybeCode !== "22P02") { |
| throw error; |
| } |
| } |
|
|
| return registry.getByKey(pluginId); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export interface PluginRouteJobDeps { |
| |
| scheduler: PluginJobScheduler; |
| |
| jobStore: PluginJobStore; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export interface PluginRouteWebhookDeps { |
| |
| workerManager: PluginWorkerManager; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export interface PluginRouteToolDeps { |
| |
| toolDispatcher: PluginToolDispatcher; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export interface PluginRouteBridgeDeps { |
| |
| workerManager: PluginWorkerManager; |
| |
| streamBus?: PluginStreamBus; |
| } |
|
|
| |
| interface PluginToolExecuteRequest { |
| |
| tool: string; |
| |
| parameters?: unknown; |
| |
| runContext: ToolRunContext; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function pluginRoutes( |
| db: Db, |
| loader: ReturnType<typeof pluginLoader>, |
| jobDeps?: PluginRouteJobDeps, |
| webhookDeps?: PluginRouteWebhookDeps, |
| toolDeps?: PluginRouteToolDeps, |
| bridgeDeps?: PluginRouteBridgeDeps, |
| ) { |
| const router = Router(); |
| const registry = pluginRegistryService(db); |
| const lifecycle = pluginLifecycleManager(db, { |
| loader, |
| workerManager: bridgeDeps?.workerManager ?? webhookDeps?.workerManager, |
| }); |
|
|
| async function resolvePluginAuditCompanyIds(req: Request): Promise<string[]> { |
| if (typeof (db as { select?: unknown }).select === "function") { |
| const rows = await db |
| .select({ id: companies.id }) |
| .from(companies); |
| return rows.map((row) => row.id); |
| } |
|
|
| if (req.actor.type === "agent" && req.actor.companyId) { |
| return [req.actor.companyId]; |
| } |
|
|
| if (req.actor.type === "board") { |
| return req.actor.companyIds ?? []; |
| } |
|
|
| return []; |
| } |
|
|
| async function logPluginMutationActivity( |
| req: Request, |
| action: string, |
| entityId: string, |
| details: Record<string, unknown>, |
| ): Promise<void> { |
| const companyIds = await resolvePluginAuditCompanyIds(req); |
| if (companyIds.length === 0) return; |
|
|
| const actor = getActorInfo(req); |
| await Promise.all(companyIds.map((companyId) => |
| logActivity(db, { |
| companyId, |
| actorType: actor.actorType, |
| actorId: actor.actorId, |
| agentId: actor.agentId, |
| runId: actor.runId, |
| action, |
| entityType: "plugin", |
| entityId, |
| details, |
| }))); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/plugins", async (req, res) => { |
| assertBoard(req); |
| const rawStatus = req.query.status; |
| if (rawStatus !== undefined) { |
| if (typeof rawStatus !== "string" || !(PLUGIN_STATUSES as readonly string[]).includes(rawStatus)) { |
| res.status(400).json({ |
| error: `Invalid status '${String(rawStatus)}'. Must be one of: ${PLUGIN_STATUSES.join(", ")}`, |
| }); |
| return; |
| } |
| } |
| const status = rawStatus as PluginStatus | undefined; |
| const plugins = status |
| ? await registry.listByStatus(status) |
| : await registry.listInstalled(); |
| res.json(plugins); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| router.get("/plugins/examples", async (req, res) => { |
| assertBoard(req); |
| res.json(listBundledPluginExamples()); |
| }); |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/plugins/ui-contributions", async (req, res) => { |
| assertBoard(req); |
| const plugins = await registry.listByStatus("ready"); |
|
|
| const contributions: PluginUiContribution[] = plugins |
| .map((plugin) => { |
| |
| const manifest = plugin.manifestJson; |
| if (!manifest) return null; |
|
|
| const uiMetadata = getPluginUiContributionMetadata(manifest); |
| if (!uiMetadata) return null; |
|
|
| return { |
| pluginId: plugin.id, |
| pluginKey: plugin.pluginKey, |
| displayName: manifest.displayName, |
| version: plugin.version, |
| updatedAt: plugin.updatedAt.toISOString(), |
| uiEntryFile: uiMetadata.uiEntryFile, |
| slots: uiMetadata.slots, |
| launchers: uiMetadata.launchers, |
| }; |
| }) |
| .filter((item): item is PluginUiContribution => item !== null); |
| res.json(contributions); |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/plugins/tools", async (req, res) => { |
| assertBoard(req); |
|
|
| if (!toolDeps) { |
| res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); |
| return; |
| } |
|
|
| const pluginId = req.query.pluginId as string | undefined; |
| const filter = pluginId ? { pluginId } : undefined; |
| const tools = toolDeps.toolDispatcher.listToolsForAgent(filter); |
| res.json(tools); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/tools/execute", async (req, res) => { |
| assertBoard(req); |
|
|
| if (!toolDeps) { |
| res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); |
| return; |
| } |
|
|
| const body = (req.body as PluginToolExecuteRequest | undefined); |
| if (!body) { |
| res.status(400).json({ error: "Request body is required" }); |
| return; |
| } |
|
|
| const { tool, parameters, runContext } = body; |
|
|
| |
| if (!tool || typeof tool !== "string") { |
| res.status(400).json({ error: '"tool" is required and must be a string' }); |
| return; |
| } |
|
|
| if (!runContext || typeof runContext !== "object") { |
| res.status(400).json({ error: '"runContext" is required and must be an object' }); |
| return; |
| } |
|
|
| if (!runContext.agentId || !runContext.runId || !runContext.companyId || !runContext.projectId) { |
| res.status(400).json({ |
| error: '"runContext" must include agentId, runId, companyId, and projectId', |
| }); |
| return; |
| } |
|
|
| assertCompanyAccess(req, runContext.companyId); |
|
|
| |
| const registeredTool = toolDeps.toolDispatcher.getTool(tool); |
| if (!registeredTool) { |
| res.status(404).json({ error: `Tool "${tool}" not found` }); |
| return; |
| } |
|
|
| try { |
| const result = await toolDeps.toolDispatcher.executeTool( |
| tool, |
| parameters ?? {}, |
| runContext, |
| ); |
| res.json(result); |
| } catch (err) { |
| const message = err instanceof Error ? err.message : String(err); |
|
|
| |
| if (message.includes("not running") || message.includes("worker")) { |
| res.status(502).json({ error: message }); |
| } else { |
| res.status(500).json({ error: message }); |
| } |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/install", async (req, res) => { |
| assertBoard(req); |
| const { packageName, version, isLocalPath } = req.body as PluginInstallRequest; |
|
|
| |
| if (!packageName || typeof packageName !== "string") { |
| res.status(400).json({ error: "packageName is required and must be a string" }); |
| return; |
| } |
|
|
| if (version !== undefined && typeof version !== "string") { |
| res.status(400).json({ error: "version must be a string if provided" }); |
| return; |
| } |
|
|
| if (isLocalPath !== undefined && typeof isLocalPath !== "boolean") { |
| res.status(400).json({ error: "isLocalPath must be a boolean if provided" }); |
| return; |
| } |
|
|
| |
| const trimmedPackage = packageName.trim(); |
| if (trimmedPackage.length === 0) { |
| res.status(400).json({ error: "packageName cannot be empty" }); |
| return; |
| } |
|
|
| |
| if (!isLocalPath && /[<>:"|?*]/.test(trimmedPackage)) { |
| res.status(400).json({ error: "packageName contains invalid characters" }); |
| return; |
| } |
|
|
| try { |
| const installOptions = isLocalPath |
| ? { localPath: trimmedPackage } |
| : { packageName: trimmedPackage, version: version?.trim() }; |
|
|
| const discovered = await loader.installPlugin(installOptions); |
|
|
| if (!discovered.manifest) { |
| res.status(500).json({ error: "Plugin installed but manifest is missing" }); |
| return; |
| } |
|
|
| |
| const existingPlugin = await registry.getByKey(discovered.manifest.id); |
| if (existingPlugin) { |
| await lifecycle.load(existingPlugin.id); |
| const updated = await registry.getById(existingPlugin.id); |
| await logPluginMutationActivity(req, "plugin.installed", existingPlugin.id, { |
| pluginId: existingPlugin.id, |
| pluginKey: existingPlugin.pluginKey, |
| packageName: updated?.packageName ?? existingPlugin.packageName, |
| version: updated?.version ?? existingPlugin.version, |
| source: isLocalPath ? "local_path" : "npm", |
| }); |
| publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: existingPlugin.id, action: "installed" } }); |
| res.json(updated); |
| } else { |
| |
| res.status(500).json({ error: "Plugin installed but not found in registry" }); |
| } |
| } catch (err) { |
| const message = err instanceof Error ? err.message : String(err); |
| res.status(400).json({ error: message }); |
| } |
| }); |
|
|
| |
| |
| |
|
|
| |
| interface PluginBridgeDataRequest { |
| |
| key: string; |
| |
| companyId?: string; |
| |
| params?: Record<string, unknown>; |
| |
| renderEnvironment?: PluginLauncherRenderContextSnapshot | null; |
| } |
|
|
| |
| interface PluginBridgeActionRequest { |
| |
| key: string; |
| |
| companyId?: string; |
| |
| params?: Record<string, unknown>; |
| |
| renderEnvironment?: PluginLauncherRenderContextSnapshot | null; |
| } |
|
|
| |
| interface PluginBridgeErrorResponse { |
| code: PluginBridgeErrorCode; |
| message: string; |
| details?: unknown; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function mapRpcErrorToBridgeError(err: unknown): PluginBridgeErrorResponse { |
| if (err instanceof JsonRpcCallError) { |
| switch (err.code) { |
| case PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE: |
| return { |
| code: "WORKER_UNAVAILABLE", |
| message: err.message, |
| details: err.data, |
| }; |
| case PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED: |
| return { |
| code: "CAPABILITY_DENIED", |
| message: err.message, |
| details: err.data, |
| }; |
| case PLUGIN_RPC_ERROR_CODES.TIMEOUT: |
| return { |
| code: "TIMEOUT", |
| message: err.message, |
| details: err.data, |
| }; |
| case PLUGIN_RPC_ERROR_CODES.WORKER_ERROR: |
| return { |
| code: "WORKER_ERROR", |
| message: err.message, |
| details: err.data, |
| }; |
| default: |
| return { |
| code: "UNKNOWN", |
| message: err.message, |
| details: err.data, |
| }; |
| } |
| } |
|
|
| const message = err instanceof Error ? err.message : String(err); |
|
|
| |
| if (message.includes("not running") || message.includes("not registered")) { |
| return { |
| code: "WORKER_UNAVAILABLE", |
| message, |
| }; |
| } |
|
|
| return { |
| code: "UNKNOWN", |
| message, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/:pluginId/bridge/data", async (req, res) => { |
| assertBoard(req); |
|
|
| if (!bridgeDeps) { |
| res.status(501).json({ error: "Plugin bridge is not enabled" }); |
| return; |
| } |
|
|
| const { pluginId } = req.params; |
|
|
| |
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| |
| if (plugin.status !== "ready") { |
| const bridgeError: PluginBridgeErrorResponse = { |
| code: "WORKER_UNAVAILABLE", |
| message: `Plugin is not ready (current status: ${plugin.status})`, |
| }; |
| res.status(502).json(bridgeError); |
| return; |
| } |
|
|
| |
| const body = req.body as PluginBridgeDataRequest | undefined; |
| if (!body || !body.key || typeof body.key !== "string") { |
| res.status(400).json({ error: '"key" is required and must be a string' }); |
| return; |
| } |
|
|
| if (body.companyId) { |
| assertCompanyAccess(req, body.companyId); |
| } |
|
|
| try { |
| const result = await bridgeDeps.workerManager.call( |
| plugin.id, |
| "getData", |
| { |
| key: body.key, |
| params: body.params ?? {}, |
| renderEnvironment: body.renderEnvironment ?? null, |
| }, |
| ); |
| res.json({ data: result }); |
| } catch (err) { |
| const bridgeError = mapRpcErrorToBridgeError(err); |
| res.status(502).json(bridgeError); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/:pluginId/bridge/action", async (req, res) => { |
| assertBoard(req); |
|
|
| if (!bridgeDeps) { |
| res.status(501).json({ error: "Plugin bridge is not enabled" }); |
| return; |
| } |
|
|
| const { pluginId } = req.params; |
|
|
| |
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| |
| if (plugin.status !== "ready") { |
| const bridgeError: PluginBridgeErrorResponse = { |
| code: "WORKER_UNAVAILABLE", |
| message: `Plugin is not ready (current status: ${plugin.status})`, |
| }; |
| res.status(502).json(bridgeError); |
| return; |
| } |
|
|
| |
| const body = req.body as PluginBridgeActionRequest | undefined; |
| if (!body || !body.key || typeof body.key !== "string") { |
| res.status(400).json({ error: '"key" is required and must be a string' }); |
| return; |
| } |
|
|
| if (body.companyId) { |
| assertCompanyAccess(req, body.companyId); |
| } |
|
|
| try { |
| const result = await bridgeDeps.workerManager.call( |
| plugin.id, |
| "performAction", |
| { |
| key: body.key, |
| params: body.params ?? {}, |
| renderEnvironment: body.renderEnvironment ?? null, |
| }, |
| ); |
| res.json({ data: result }); |
| } catch (err) { |
| const bridgeError = mapRpcErrorToBridgeError(err); |
| res.status(502).json(bridgeError); |
| } |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/:pluginId/data/:key", async (req, res) => { |
| assertBoard(req); |
|
|
| if (!bridgeDeps) { |
| res.status(501).json({ error: "Plugin bridge is not enabled" }); |
| return; |
| } |
|
|
| const { pluginId, key } = req.params; |
|
|
| |
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| |
| if (plugin.status !== "ready") { |
| const bridgeError: PluginBridgeErrorResponse = { |
| code: "WORKER_UNAVAILABLE", |
| message: `Plugin is not ready (current status: ${plugin.status})`, |
| }; |
| res.status(502).json(bridgeError); |
| return; |
| } |
|
|
| const body = req.body as { |
| companyId?: string; |
| params?: Record<string, unknown>; |
| renderEnvironment?: PluginLauncherRenderContextSnapshot | null; |
| } | undefined; |
|
|
| if (body?.companyId) { |
| assertCompanyAccess(req, body.companyId); |
| } |
|
|
| try { |
| const result = await bridgeDeps.workerManager.call( |
| plugin.id, |
| "getData", |
| { |
| key, |
| params: body?.params ?? {}, |
| renderEnvironment: body?.renderEnvironment ?? null, |
| }, |
| ); |
| res.json({ data: result }); |
| } catch (err) { |
| const bridgeError = mapRpcErrorToBridgeError(err); |
| res.status(502).json(bridgeError); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/:pluginId/actions/:key", async (req, res) => { |
| assertBoard(req); |
|
|
| if (!bridgeDeps) { |
| res.status(501).json({ error: "Plugin bridge is not enabled" }); |
| return; |
| } |
|
|
| const { pluginId, key } = req.params; |
|
|
| |
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| |
| if (plugin.status !== "ready") { |
| const bridgeError: PluginBridgeErrorResponse = { |
| code: "WORKER_UNAVAILABLE", |
| message: `Plugin is not ready (current status: ${plugin.status})`, |
| }; |
| res.status(502).json(bridgeError); |
| return; |
| } |
|
|
| const body = req.body as { |
| companyId?: string; |
| params?: Record<string, unknown>; |
| renderEnvironment?: PluginLauncherRenderContextSnapshot | null; |
| } | undefined; |
|
|
| if (body?.companyId) { |
| assertCompanyAccess(req, body.companyId); |
| } |
|
|
| try { |
| const result = await bridgeDeps.workerManager.call( |
| plugin.id, |
| "performAction", |
| { |
| key, |
| params: body?.params ?? {}, |
| renderEnvironment: body?.renderEnvironment ?? null, |
| }, |
| ); |
| res.json({ data: result }); |
| } catch (err) { |
| const bridgeError = mapRpcErrorToBridgeError(err); |
| res.status(502).json(bridgeError); |
| } |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/plugins/:pluginId/bridge/stream/:channel", async (req, res) => { |
| assertBoard(req); |
|
|
| if (!bridgeDeps?.streamBus) { |
| res.status(501).json({ error: "Plugin stream bridge is not enabled" }); |
| return; |
| } |
|
|
| const { pluginId, channel } = req.params; |
| const companyId = req.query.companyId as string | undefined; |
|
|
| if (!companyId) { |
| res.status(400).json({ error: '"companyId" query parameter is required' }); |
| return; |
| } |
|
|
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| assertCompanyAccess(req, companyId); |
|
|
| |
| res.writeHead(200, { |
| "Content-Type": "text/event-stream", |
| "Cache-Control": "no-cache", |
| "Connection": "keep-alive", |
| "X-Accel-Buffering": "no", |
| }); |
| res.flushHeaders(); |
|
|
| |
| res.write(":ok\n\n"); |
|
|
| let unsubscribed = false; |
| const safeUnsubscribe = () => { |
| if (!unsubscribed) { |
| unsubscribed = true; |
| unsubscribe(); |
| } |
| }; |
|
|
| const unsubscribe = bridgeDeps.streamBus.subscribe( |
| plugin.id, |
| channel, |
| companyId, |
| (event, eventType) => { |
| if (unsubscribed || !res.writable) return; |
| try { |
| if (eventType !== "message") { |
| res.write(`event: ${eventType}\n`); |
| } |
| res.write(`data: ${JSON.stringify(event)}\n\n`); |
| } catch { |
| |
| safeUnsubscribe(); |
| } |
| }, |
| ); |
|
|
| req.on("close", safeUnsubscribe); |
| res.on("error", safeUnsubscribe); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/plugins/:pluginId", async (req, res) => { |
| assertBoard(req); |
| const { pluginId } = req.params; |
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| |
| const worker = bridgeDeps?.workerManager.getWorker(plugin.id); |
| const supportsConfigTest = worker |
| ? worker.supportedMethods.includes("validateConfig") |
| : false; |
|
|
| res.json({ ...plugin, supportsConfigTest }); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.delete("/plugins/:pluginId", async (req, res) => { |
| assertBoard(req); |
| const { pluginId } = req.params; |
| const purge = req.query.purge === "true"; |
|
|
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| try { |
| const result = await lifecycle.unload(plugin.id, purge); |
| await logPluginMutationActivity(req, "plugin.uninstalled", plugin.id, { |
| pluginId: plugin.id, |
| pluginKey: plugin.pluginKey, |
| purge, |
| }); |
| publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "uninstalled" } }); |
| res.json(result); |
| } catch (err) { |
| const message = err instanceof Error ? err.message : String(err); |
| res.status(400).json({ error: message }); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/:pluginId/enable", async (req, res) => { |
| assertBoard(req); |
| const { pluginId } = req.params; |
|
|
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| try { |
| const result = await lifecycle.enable(plugin.id); |
| await logPluginMutationActivity(req, "plugin.enabled", plugin.id, { |
| pluginId: plugin.id, |
| pluginKey: plugin.pluginKey, |
| version: result?.version ?? plugin.version, |
| }); |
| publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "enabled" } }); |
| res.json(result); |
| } catch (err) { |
| const message = err instanceof Error ? err.message : String(err); |
| res.status(400).json({ error: message }); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/:pluginId/disable", async (req, res) => { |
| assertBoard(req); |
| const { pluginId } = req.params; |
| const body = req.body as { reason?: string } | undefined; |
| const reason = body?.reason; |
|
|
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| try { |
| const result = await lifecycle.disable(plugin.id, reason); |
| await logPluginMutationActivity(req, "plugin.disabled", plugin.id, { |
| pluginId: plugin.id, |
| pluginKey: plugin.pluginKey, |
| reason: reason ?? null, |
| }); |
| publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "disabled" } }); |
| res.json(result); |
| } catch (err) { |
| const message = err instanceof Error ? err.message : String(err); |
| res.status(400).json({ error: message }); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/plugins/:pluginId/health", async (req, res) => { |
| assertBoard(req); |
| const { pluginId } = req.params; |
|
|
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| const checks: PluginHealthCheckResult["checks"] = []; |
|
|
| |
| checks.push({ |
| name: "registry", |
| passed: true, |
| message: "Plugin found in registry", |
| }); |
|
|
| |
| const hasValidManifest = Boolean(plugin.manifestJson?.id); |
| checks.push({ |
| name: "manifest", |
| passed: hasValidManifest, |
| message: hasValidManifest ? "Manifest is valid" : "Manifest is invalid or missing", |
| }); |
|
|
| |
| const isHealthy = plugin.status === "ready"; |
| checks.push({ |
| name: "status", |
| passed: isHealthy, |
| message: `Current status: ${plugin.status}`, |
| }); |
|
|
| |
| const hasNoError = !plugin.lastError; |
| if (!hasNoError) { |
| checks.push({ |
| name: "error_state", |
| passed: false, |
| message: plugin.lastError ?? undefined, |
| }); |
| } |
|
|
| const result: PluginHealthCheckResult = { |
| pluginId: plugin.id, |
| status: plugin.status, |
| healthy: isHealthy && hasValidManifest && hasNoError, |
| checks, |
| lastError: plugin.lastError ?? undefined, |
| }; |
|
|
| res.json(result); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/plugins/:pluginId/logs", async (req, res) => { |
| assertBoard(req); |
| const { pluginId } = req.params; |
|
|
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 25, 1), 500); |
| const level = req.query.level as string | undefined; |
| const since = req.query.since as string | undefined; |
|
|
| const conditions = [eq(pluginLogs.pluginId, plugin.id)]; |
| if (level) { |
| conditions.push(eq(pluginLogs.level, level)); |
| } |
| if (since) { |
| const sinceDate = new Date(since); |
| if (!isNaN(sinceDate.getTime())) { |
| conditions.push(gte(pluginLogs.createdAt, sinceDate)); |
| } |
| } |
|
|
| const rows = await db |
| .select() |
| .from(pluginLogs) |
| .where(and(...conditions)) |
| .orderBy(desc(pluginLogs.createdAt)) |
| .limit(limit); |
|
|
| res.json(rows); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/:pluginId/upgrade", async (req, res) => { |
| assertBoard(req); |
| const { pluginId } = req.params; |
| const body = req.body as { version?: string } | undefined; |
| const version = body?.version; |
|
|
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| try { |
| |
| |
| |
| |
| |
| const result = await lifecycle.upgrade(plugin.id, version); |
| await logPluginMutationActivity(req, "plugin.upgraded", plugin.id, { |
| pluginId: plugin.id, |
| pluginKey: plugin.pluginKey, |
| previousVersion: plugin.version, |
| version: result?.version ?? plugin.version, |
| targetVersion: version ?? null, |
| }); |
| publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "upgraded" } }); |
| res.json(result); |
| } catch (err) { |
| const message = err instanceof Error ? err.message : String(err); |
| res.status(400).json({ error: message }); |
| } |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/plugins/:pluginId/config", async (req, res) => { |
| assertBoard(req); |
| const { pluginId } = req.params; |
|
|
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| const config = await registry.getConfig(plugin.id); |
| res.json(config); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/:pluginId/config", async (req, res) => { |
| assertBoard(req); |
| const { pluginId } = req.params; |
|
|
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| const body = req.body as { configJson?: Record<string, unknown> } | undefined; |
| if (!body?.configJson || typeof body.configJson !== "object") { |
| res.status(400).json({ error: '"configJson" is required and must be an object' }); |
| return; |
| } |
|
|
| |
| |
| |
| if ( |
| "devUiUrl" in body.configJson && |
| !(req.actor.type === "board" && req.actor.isInstanceAdmin) |
| ) { |
| delete body.configJson.devUiUrl; |
| } |
|
|
| |
| |
| const schema = plugin.manifestJson?.instanceConfigSchema; |
| if (schema && Object.keys(schema).length > 0) { |
| const validation = validateInstanceConfig(body.configJson, schema); |
| if (!validation.valid) { |
| res.status(400).json({ |
| error: "Configuration does not match the plugin's instanceConfigSchema", |
| fieldErrors: validation.errors, |
| }); |
| return; |
| } |
| } |
|
|
| try { |
| const result = await registry.upsertConfig(plugin.id, { |
| configJson: body.configJson, |
| }); |
| await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, { |
| pluginId: plugin.id, |
| pluginKey: plugin.pluginKey, |
| configKeyCount: Object.keys(body.configJson).length, |
| }); |
|
|
| |
| |
| |
| |
| if (bridgeDeps?.workerManager.isRunning(plugin.id)) { |
| try { |
| await bridgeDeps.workerManager.call( |
| plugin.id, |
| "configChanged", |
| { config: body.configJson }, |
| ); |
| } catch (rpcErr) { |
| if ( |
| rpcErr instanceof JsonRpcCallError && |
| rpcErr.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED |
| ) { |
| |
| try { |
| await lifecycle.restartWorker(plugin.id); |
| } catch { |
| |
| } |
| } |
| |
| |
| } |
| } |
|
|
| res.json(result); |
| } catch (err) { |
| const message = err instanceof Error ? err.message : String(err); |
| res.status(400).json({ error: message }); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/:pluginId/config/test", async (req, res) => { |
| assertBoard(req); |
|
|
| if (!bridgeDeps) { |
| res.status(501).json({ error: "Plugin bridge is not enabled" }); |
| return; |
| } |
|
|
| const { pluginId } = req.params; |
|
|
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| if (plugin.status !== "ready") { |
| res.status(400).json({ |
| error: `Plugin is not ready (current status: ${plugin.status})`, |
| }); |
| return; |
| } |
|
|
| const body = req.body as { configJson?: Record<string, unknown> } | undefined; |
| if (!body?.configJson || typeof body.configJson !== "object") { |
| res.status(400).json({ error: '"configJson" is required and must be an object' }); |
| return; |
| } |
|
|
| |
| const schema = plugin.manifestJson?.instanceConfigSchema; |
| if (schema && Object.keys(schema).length > 0) { |
| const validation = validateInstanceConfig(body.configJson, schema); |
| if (!validation.valid) { |
| res.status(400).json({ |
| error: "Configuration does not match the plugin's instanceConfigSchema", |
| fieldErrors: validation.errors, |
| }); |
| return; |
| } |
| } |
|
|
| try { |
| const result = await bridgeDeps.workerManager.call( |
| plugin.id, |
| "validateConfig", |
| { config: body.configJson }, |
| ); |
|
|
| |
| |
| if (result.ok) { |
| const warningText = result.warnings?.length |
| ? `Warnings: ${result.warnings.join("; ")}` |
| : undefined; |
| res.json({ valid: true, message: warningText }); |
| } else { |
| const errorText = result.errors?.length |
| ? result.errors.join("; ") |
| : "Configuration validation failed."; |
| res.json({ valid: false, message: errorText }); |
| } |
| } catch (err) { |
| |
| if ( |
| err instanceof JsonRpcCallError && |
| err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED |
| ) { |
| res.json({ |
| valid: false, |
| supported: false, |
| message: "This plugin does not support configuration testing.", |
| }); |
| return; |
| } |
|
|
| |
| const bridgeError = mapRpcErrorToBridgeError(err); |
| res.status(502).json(bridgeError); |
| } |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/plugins/:pluginId/jobs", async (req, res) => { |
| assertBoard(req); |
| if (!jobDeps) { |
| res.status(501).json({ error: "Job scheduling is not enabled" }); |
| return; |
| } |
|
|
| const { pluginId } = req.params; |
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| const rawStatus = req.query.status as string | undefined; |
| const validStatuses = ["active", "paused", "failed"]; |
| if (rawStatus !== undefined && !validStatuses.includes(rawStatus)) { |
| res.status(400).json({ |
| error: `Invalid status '${rawStatus}'. Must be one of: ${validStatuses.join(", ")}`, |
| }); |
| return; |
| } |
|
|
| try { |
| const jobs = await jobDeps.jobStore.listJobs( |
| plugin.id, |
| rawStatus as "active" | "paused" | "failed" | undefined, |
| ); |
| res.json(jobs); |
| } catch (err) { |
| const message = err instanceof Error ? err.message : String(err); |
| res.status(500).json({ error: message }); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/plugins/:pluginId/jobs/:jobId/runs", async (req, res) => { |
| assertBoard(req); |
| if (!jobDeps) { |
| res.status(501).json({ error: "Job scheduling is not enabled" }); |
| return; |
| } |
|
|
| const { pluginId, jobId } = req.params; |
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| const job = await jobDeps.jobStore.getJobByIdForPlugin(plugin.id, jobId); |
| if (!job) { |
| res.status(404).json({ error: "Job not found" }); |
| return; |
| } |
|
|
| const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 25; |
| if (isNaN(limit) || limit < 1 || limit > 500) { |
| res.status(400).json({ error: "limit must be a number between 1 and 500" }); |
| return; |
| } |
|
|
| try { |
| const runs = await jobDeps.jobStore.listRunsByJob(jobId, limit); |
| res.json(runs); |
| } catch (err) { |
| const message = err instanceof Error ? err.message : String(err); |
| res.status(500).json({ error: message }); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => { |
| assertBoard(req); |
| if (!jobDeps) { |
| res.status(501).json({ error: "Job scheduling is not enabled" }); |
| return; |
| } |
|
|
| const { pluginId, jobId } = req.params; |
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| const job = await jobDeps.jobStore.getJobByIdForPlugin(plugin.id, jobId); |
| if (!job) { |
| res.status(404).json({ error: "Job not found" }); |
| return; |
| } |
|
|
| try { |
| const result = await jobDeps.scheduler.triggerJob(jobId, "manual"); |
| res.json(result); |
| } catch (err) { |
| const message = err instanceof Error ? err.message : String(err); |
| res.status(400).json({ error: message }); |
| } |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.post("/plugins/:pluginId/webhooks/:endpointKey", async (req, res) => { |
| if (!webhookDeps) { |
| res.status(501).json({ error: "Webhook ingestion is not enabled" }); |
| return; |
| } |
|
|
| const { pluginId, endpointKey } = req.params; |
|
|
| |
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| |
| if (plugin.status !== "ready") { |
| res.status(400).json({ |
| error: `Plugin is not ready (current status: ${plugin.status})`, |
| }); |
| return; |
| } |
|
|
| |
| const manifest = plugin.manifestJson; |
| if (!manifest) { |
| res.status(400).json({ error: "Plugin manifest is missing" }); |
| return; |
| } |
|
|
| const capabilities = manifest.capabilities ?? []; |
| if (!capabilities.includes("webhooks.receive")) { |
| res.status(400).json({ |
| error: "Plugin does not have the webhooks.receive capability", |
| }); |
| return; |
| } |
|
|
| |
| const declaredWebhooks = manifest.webhooks ?? []; |
| const webhookDecl = declaredWebhooks.find( |
| (w) => w.endpointKey === endpointKey, |
| ); |
| if (!webhookDecl) { |
| res.status(404).json({ |
| error: `Webhook endpoint '${endpointKey}' is not declared by this plugin`, |
| }); |
| return; |
| } |
|
|
| |
| const requestId = randomUUID(); |
| const rawHeaders: Record<string, string> = {}; |
| for (const [key, value] of Object.entries(req.headers)) { |
| if (typeof value === "string") { |
| rawHeaders[key] = value; |
| } else if (Array.isArray(value)) { |
| rawHeaders[key] = value.join(", "); |
| } |
| } |
|
|
| |
| |
| |
| const stashedRaw = (req as unknown as { rawBody?: Buffer }).rawBody; |
| const rawBody = stashedRaw ? stashedRaw.toString("utf-8") : ""; |
| const parsedBody = req.body as unknown; |
| const payload = (req.body as Record<string, unknown> | undefined) ?? {}; |
|
|
| |
| const startedAt = new Date(); |
| const [delivery] = await db |
| .insert(pluginWebhookDeliveries) |
| .values({ |
| pluginId: plugin.id, |
| webhookKey: endpointKey, |
| status: "pending", |
| payload, |
| headers: rawHeaders, |
| startedAt, |
| }) |
| .returning({ id: pluginWebhookDeliveries.id }); |
|
|
| |
| try { |
| await webhookDeps.workerManager.call(plugin.id, "handleWebhook", { |
| endpointKey, |
| headers: req.headers as Record<string, string | string[]>, |
| rawBody, |
| parsedBody, |
| requestId, |
| }); |
|
|
| |
| const finishedAt = new Date(); |
| const durationMs = finishedAt.getTime() - startedAt.getTime(); |
| await db |
| .update(pluginWebhookDeliveries) |
| .set({ |
| status: "success", |
| durationMs, |
| finishedAt, |
| }) |
| .where(eq(pluginWebhookDeliveries.id, delivery.id)); |
|
|
| res.status(200).json({ |
| deliveryId: delivery.id, |
| status: "success", |
| }); |
| } catch (err) { |
| |
| const finishedAt = new Date(); |
| const durationMs = finishedAt.getTime() - startedAt.getTime(); |
| const errorMessage = err instanceof Error ? err.message : String(err); |
|
|
| await db |
| .update(pluginWebhookDeliveries) |
| .set({ |
| status: "failed", |
| durationMs, |
| error: errorMessage, |
| finishedAt, |
| }) |
| .where(eq(pluginWebhookDeliveries.id, delivery.id)); |
|
|
| res.status(502).json({ |
| deliveryId: delivery.id, |
| status: "failed", |
| error: errorMessage, |
| }); |
| } |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/plugins/:pluginId/dashboard", async (req, res) => { |
| assertBoard(req); |
| const { pluginId } = req.params; |
|
|
| const plugin = await resolvePlugin(registry, pluginId); |
| if (!plugin) { |
| res.status(404).json({ error: "Plugin not found" }); |
| return; |
| } |
|
|
| |
| let worker: { |
| status: string; |
| pid: number | null; |
| uptime: number | null; |
| consecutiveCrashes: number; |
| totalCrashes: number; |
| pendingRequests: number; |
| lastCrashAt: number | null; |
| nextRestartAt: number | null; |
| } | null = null; |
|
|
| |
| const wm = bridgeDeps?.workerManager ?? webhookDeps?.workerManager ?? null; |
| if (wm) { |
| const handle = wm.getWorker(plugin.id); |
| if (handle) { |
| const diag = handle.diagnostics(); |
| worker = { |
| status: diag.status, |
| pid: diag.pid, |
| uptime: diag.uptime, |
| consecutiveCrashes: diag.consecutiveCrashes, |
| totalCrashes: diag.totalCrashes, |
| pendingRequests: diag.pendingRequests, |
| lastCrashAt: diag.lastCrashAt, |
| nextRestartAt: diag.nextRestartAt, |
| }; |
| } |
| } |
|
|
| |
| let recentJobRuns: Array<{ |
| id: string; |
| jobId: string; |
| jobKey?: string; |
| trigger: string; |
| status: string; |
| durationMs: number | null; |
| error: string | null; |
| startedAt: string | null; |
| finishedAt: string | null; |
| createdAt: string; |
| }> = []; |
|
|
| if (jobDeps) { |
| try { |
| const runs = await jobDeps.jobStore.listRunsByPlugin(plugin.id, undefined, 10); |
| |
| const jobs = await jobDeps.jobStore.listJobs(plugin.id); |
| const jobKeyMap = new Map(jobs.map((j) => [j.id, j.jobKey])); |
|
|
| recentJobRuns = runs |
| .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) |
| .map((r) => ({ |
| id: r.id, |
| jobId: r.jobId, |
| jobKey: jobKeyMap.get(r.jobId) ?? undefined, |
| trigger: r.trigger, |
| status: r.status, |
| durationMs: r.durationMs, |
| error: r.error, |
| startedAt: r.startedAt ? new Date(r.startedAt).toISOString() : null, |
| finishedAt: r.finishedAt ? new Date(r.finishedAt).toISOString() : null, |
| createdAt: new Date(r.createdAt).toISOString(), |
| })); |
| } catch { |
| |
| } |
| } |
|
|
| |
| let recentWebhookDeliveries: Array<{ |
| id: string; |
| webhookKey: string; |
| status: string; |
| durationMs: number | null; |
| error: string | null; |
| startedAt: string | null; |
| finishedAt: string | null; |
| createdAt: string; |
| }> = []; |
|
|
| try { |
| const deliveries = await db |
| .select({ |
| id: pluginWebhookDeliveries.id, |
| webhookKey: pluginWebhookDeliveries.webhookKey, |
| status: pluginWebhookDeliveries.status, |
| durationMs: pluginWebhookDeliveries.durationMs, |
| error: pluginWebhookDeliveries.error, |
| startedAt: pluginWebhookDeliveries.startedAt, |
| finishedAt: pluginWebhookDeliveries.finishedAt, |
| createdAt: pluginWebhookDeliveries.createdAt, |
| }) |
| .from(pluginWebhookDeliveries) |
| .where(eq(pluginWebhookDeliveries.pluginId, plugin.id)) |
| .orderBy(desc(pluginWebhookDeliveries.createdAt)) |
| .limit(10); |
|
|
| recentWebhookDeliveries = deliveries.map((d) => ({ |
| id: d.id, |
| webhookKey: d.webhookKey, |
| status: d.status, |
| durationMs: d.durationMs, |
| error: d.error, |
| startedAt: d.startedAt ? d.startedAt.toISOString() : null, |
| finishedAt: d.finishedAt ? d.finishedAt.toISOString() : null, |
| createdAt: d.createdAt.toISOString(), |
| })); |
| } catch { |
| |
| } |
|
|
| |
| const checks: PluginHealthCheckResult["checks"] = []; |
|
|
| checks.push({ |
| name: "registry", |
| passed: true, |
| message: "Plugin found in registry", |
| }); |
|
|
| const hasValidManifest = Boolean(plugin.manifestJson?.id); |
| checks.push({ |
| name: "manifest", |
| passed: hasValidManifest, |
| message: hasValidManifest ? "Manifest is valid" : "Manifest is invalid or missing", |
| }); |
|
|
| const isHealthy = plugin.status === "ready"; |
| checks.push({ |
| name: "status", |
| passed: isHealthy, |
| message: `Current status: ${plugin.status}`, |
| }); |
|
|
| const hasNoError = !plugin.lastError; |
| if (!hasNoError) { |
| checks.push({ |
| name: "error_state", |
| passed: false, |
| message: plugin.lastError ?? undefined, |
| }); |
| } |
|
|
| const health: PluginHealthCheckResult = { |
| pluginId: plugin.id, |
| status: plugin.status, |
| healthy: isHealthy && hasValidManifest && hasNoError, |
| checks, |
| lastError: plugin.lastError ?? undefined, |
| }; |
|
|
| res.json({ |
| pluginId: plugin.id, |
| worker, |
| recentJobRuns, |
| recentWebhookDeliveries, |
| health, |
| checkedAt: new Date().toISOString(), |
| }); |
| }); |
|
|
| return router; |
| } |
|
|