From 5ae4595bb9cc33e3caa68d661f1ab5ceb4053e86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Feb 2026 17:22:20 +0000 Subject: [PATCH] refactor(plugins): reuse plugin service runtime context --- src/plugins/services.test.ts | 127 +++++++++++++++++++++++++++++++++++ src/plugins/services.ts | 53 ++++++++------- 2 files changed, 155 insertions(+), 25 deletions(-) create mode 100644 src/plugins/services.test.ts diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts new file mode 100644 index 0000000000..f508396362 --- /dev/null +++ b/src/plugins/services.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "./registry.js"; +import type { OpenClawPluginService, OpenClawPluginServiceContext } from "./types.js"; + +const mockedLogger = vi.hoisted(() => ({ + info: vi.fn<(msg: string) => void>(), + warn: vi.fn<(msg: string) => void>(), + error: vi.fn<(msg: string) => void>(), + debug: vi.fn<(msg: string) => void>(), +})); + +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: () => mockedLogger, +})); + +import { STATE_DIR } from "../config/paths.js"; +import { startPluginServices } from "./services.js"; + +function createRegistry(services: OpenClawPluginService[]) { + const registry = createEmptyPluginRegistry(); + for (const service of services) { + registry.services.push({ pluginId: "plugin:test", service, source: "test" }); + } + return registry; +} + +describe("startPluginServices", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("starts services and stops them in reverse order", async () => { + const starts: string[] = []; + const stops: string[] = []; + const contexts: OpenClawPluginServiceContext[] = []; + + const serviceA: OpenClawPluginService = { + id: "service-a", + start: (ctx) => { + starts.push("a"); + contexts.push(ctx); + }, + stop: () => { + stops.push("a"); + }, + }; + const serviceB: OpenClawPluginService = { + id: "service-b", + start: (ctx) => { + starts.push("b"); + contexts.push(ctx); + }, + }; + const serviceC: OpenClawPluginService = { + id: "service-c", + start: (ctx) => { + starts.push("c"); + contexts.push(ctx); + }, + stop: () => { + stops.push("c"); + }, + }; + + const config = {} as Parameters[0]["config"]; + const handle = await startPluginServices({ + registry: createRegistry([serviceA, serviceB, serviceC]), + config, + workspaceDir: "/tmp/workspace", + }); + await handle.stop(); + + expect(starts).toEqual(["a", "b", "c"]); + expect(stops).toEqual(["c", "a"]); + expect(contexts).toHaveLength(3); + for (const ctx of contexts) { + expect(ctx.config).toBe(config); + expect(ctx.workspaceDir).toBe("/tmp/workspace"); + expect(ctx.stateDir).toBe(STATE_DIR); + expect(ctx.logger).toBeDefined(); + expect(typeof ctx.logger.info).toBe("function"); + expect(typeof ctx.logger.warn).toBe("function"); + expect(typeof ctx.logger.error).toBe("function"); + } + }); + + it("logs start/stop failures and continues", async () => { + const stopOk = vi.fn(); + const stopThrows = vi.fn(() => { + throw new Error("stop failed"); + }); + + const handle = await startPluginServices({ + registry: createRegistry([ + { + id: "service-start-fail", + start: () => { + throw new Error("start failed"); + }, + stop: vi.fn(), + }, + { + id: "service-ok", + start: () => undefined, + stop: stopOk, + }, + { + id: "service-stop-fail", + start: () => undefined, + stop: stopThrows, + }, + ]), + config: {} as Parameters[0]["config"], + }); + + await handle.stop(); + + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining("plugin service failed (service-start-fail):"), + ); + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("plugin service stop failed (service-stop-fail):"), + ); + expect(stopOk).toHaveBeenCalledOnce(); + expect(stopThrows).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/plugins/services.ts b/src/plugins/services.ts index 8c71300c20..751df4f874 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -2,9 +2,31 @@ import type { OpenClawConfig } from "../config/config.js"; import { STATE_DIR } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { PluginRegistry } from "./registry.js"; +import type { OpenClawPluginServiceContext, PluginLogger } from "./types.js"; const log = createSubsystemLogger("plugins"); +function createPluginLogger(): PluginLogger { + return { + info: (msg) => log.info(msg), + warn: (msg) => log.warn(msg), + error: (msg) => log.error(msg), + debug: (msg) => log.debug(msg), + }; +} + +function createServiceContext(params: { + config: OpenClawConfig; + workspaceDir?: string; +}): OpenClawPluginServiceContext { + return { + config: params.config, + workspaceDir: params.workspaceDir, + stateDir: STATE_DIR, + logger: createPluginLogger(), + }; +} + export type PluginServicesHandle = { stop: () => Promise; }; @@ -18,37 +40,18 @@ export async function startPluginServices(params: { id: string; stop?: () => void | Promise; }> = []; + const serviceContext = createServiceContext({ + config: params.config, + workspaceDir: params.workspaceDir, + }); for (const entry of params.registry.services) { const service = entry.service; try { - await service.start({ - config: params.config, - workspaceDir: params.workspaceDir, - stateDir: STATE_DIR, - logger: { - info: (msg) => log.info(msg), - warn: (msg) => log.warn(msg), - error: (msg) => log.error(msg), - debug: (msg) => log.debug(msg), - }, - }); + await service.start(serviceContext); running.push({ id: service.id, - stop: service.stop - ? () => - service.stop?.({ - config: params.config, - workspaceDir: params.workspaceDir, - stateDir: STATE_DIR, - logger: { - info: (msg) => log.info(msg), - warn: (msg) => log.warn(msg), - error: (msg) => log.error(msg), - debug: (msg) => log.debug(msg), - }, - }) - : undefined, + stop: service.stop ? () => service.stop?.(serviceContext) : undefined, }); } catch (err) { log.error(`plugin service failed (${service.id}): ${String(err)}`);