diff --git a/server/src/__tests__/plugin-config-startup-delivery.test.ts b/server/src/__tests__/plugin-config-startup-delivery.test.ts new file mode 100644 index 0000000000..58b02fbb25 --- /dev/null +++ b/server/src/__tests__/plugin-config-startup-delivery.test.ts @@ -0,0 +1,136 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, createDb, pluginConfig, plugins } from "@paperclipai/db"; +import { pluginRegistryService } from "../services/plugin-registry.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +/** + * LOOA-629: a plugin worker is spawned once per plugin (not per company) with + * an empty bootstrap config, and can only read company-scoped config from + * inside a company-scoped invocation. A proactive plugin (e.g. the chat + * gateway) has no such invocation at setup(), so the loader must replay every + * configured company's config to the freshly-started worker. That replay reads + * the config rows via `registry.listConfigs(pluginId)`, which this exercises. + */ + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping plugin config startup-delivery tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +function issuePrefix(id: string) { + return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`; +} + +describeEmbeddedPostgres("registry.listConfigs (startup config delivery)", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-config-delivery-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(pluginConfig); + await db.delete(plugins); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedPlugin(pluginKey: string, installOrder: number) { + const pluginId = randomUUID(); + await db.insert(plugins).values({ + id: pluginId, + pluginKey, + packageName: `@paperclipai/${pluginKey}`, + version: "0.0.1", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: pluginKey, + apiVersion: 1, + version: "0.0.1", + displayName: pluginKey, + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: { worker: "./dist/worker.js" }, + }, + status: "ready", + installOrder, + }); + return pluginId; + } + + async function seedCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: `Co ${companyId.slice(0, 6)}`, + issuePrefix: issuePrefix(companyId), + }); + return companyId; + } + + it("returns every company-scoped config row for a plugin", async () => { + const registry = pluginRegistryService(db); + const pluginId = await seedPlugin("paperclip.gateway-test", 1); + const companyA = await seedCompany(); + const companyB = await seedCompany(); + + await registry.upsertConfig(pluginId, companyA, { + companyId: companyA, + configJson: { slackBotToken: "xoxb-a", slackAppToken: "xapp-a" }, + }); + await registry.upsertConfig(pluginId, companyB, { + companyId: companyB, + configJson: { slackBotToken: "xoxb-b", slackAppToken: "xapp-b" }, + }); + + const rows = await registry.listConfigs(pluginId); + expect(rows).toHaveLength(2); + + const byCompany = new Map(rows.map((r) => [r.companyId, r])); + expect(byCompany.get(companyA)?.configJson).toMatchObject({ slackBotToken: "xoxb-a" }); + expect(byCompany.get(companyB)?.configJson).toMatchObject({ slackBotToken: "xoxb-b" }); + }); + + it("only returns rows for the requested plugin (no cross-plugin bleed)", async () => { + const registry = pluginRegistryService(db); + const pluginId = await seedPlugin("paperclip.gateway-test", 1); + const otherPluginId = await seedPlugin("paperclip.other-test", 2); + const companyA = await seedCompany(); + + await registry.upsertConfig(pluginId, companyA, { + companyId: companyA, + configJson: { marker: "mine" }, + }); + await registry.upsertConfig(otherPluginId, companyA, { + companyId: companyA, + configJson: { marker: "theirs" }, + }); + + const rows = await registry.listConfigs(pluginId); + expect(rows).toHaveLength(1); + expect(rows[0]?.configJson).toMatchObject({ marker: "mine" }); + }); + + it("returns an empty list when the plugin has no configured companies", async () => { + const registry = pluginRegistryService(db); + const pluginId = await seedPlugin("paperclip.gateway-test", 1); + const rows = await registry.listConfigs(pluginId); + expect(rows).toEqual([]); + }); +}); diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index faa0517fa0..aded537056 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -2137,6 +2137,8 @@ export function pluginLoader( // ------------------------------------------------------------------ // Plugin configuration is company-scoped. Workers receive an empty // bootstrap config and must use ctx.config.get(companyId) at runtime. + // Stored config is delivered right after the worker starts (step 5b) via + // the same configChanged path an operator config-save uses. const config: Record = {}; // ------------------------------------------------------------------ @@ -2169,6 +2171,52 @@ export function pluginLoader( "plugin-loader: worker started", ); + // ------------------------------------------------------------------ + // 5b. Deliver stored configuration to the freshly-started worker + // ------------------------------------------------------------------ + // The worker is spawned with an empty bootstrap config and is expected to + // read company-scoped config via ctx.config.get(companyId). That call + // only resolves inside a company-scoped invocation (event/action/tool), + // so a proactive plugin that does company work from setup() — e.g. the + // chat gateway opening a Slack Socket Mode connection — can never read + // its own config and comes up inert. Replay each configured company's + // config through the same configChanged path an operator config-save + // uses (routes/plugins.ts), so the worker receives it at startup. + // Best-effort: a worker that doesn't implement onConfigChanged + // (METHOD_NOT_IMPLEMENTED) or is momentarily unavailable simply keeps the + // runtime ctx.config.get(companyId) model. onConfigChanged is idempotent + // for well-behaved plugins, so replaying an unchanged config is safe. + try { + const configRows = await registry.listConfigs(pluginId); + for (const row of configRows) { + try { + await workerManager.call(pluginId, "configChanged", { + config: (row.configJson ?? {}) as Record, + companyId: row.companyId, + }); + } catch (configErr) { + log.debug( + { + pluginId, + pluginKey, + companyId: row.companyId, + err: configErr instanceof Error ? configErr.message : String(configErr), + }, + "plugin-loader: startup config delivery skipped for company", + ); + } + } + } catch (listErr) { + log.debug( + { + pluginId, + pluginKey, + err: listErr instanceof Error ? listErr.message : String(listErr), + }, + "plugin-loader: could not list stored configs for startup delivery", + ); + } + // ------------------------------------------------------------------ // 6. Sync job declarations and register with scheduler // ------------------------------------------------------------------ diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index 1ee05092fb..344cc26c9c 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -288,6 +288,22 @@ export function pluginRegistryService(db: Db) { .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId))) .then((rows) => rows[0] ?? null), + /** + * List every company-scoped configuration row for a plugin. + * + * Plugin config is company-scoped, but a worker is spawned once per plugin + * (not per company). Callers such as the plugin loader use this to replay + * each configured company's config to a freshly-started worker, so a + * proactive plugin that never runs inside a company-scoped invocation (and + * therefore cannot resolve `ctx.config.get(companyId)`) still receives its + * configuration at startup. + */ + listConfigs: (pluginId: string) => + db + .select() + .from(pluginConfig) + .where(eq(pluginConfig.pluginId, pluginId)), + /** * Create or fully replace a plugin's company-scoped configuration. * If a config row already exists for the plugin/company pair it is replaced;