From 6b24e65719bab163a74a6c50ea97cdb2bf5a2b79 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 17:25:32 +0000 Subject: [PATCH] Plugins: extract service lifecycle --- src/extension-host/service-lifecycle.test.ts | 127 +++++++++++++++++++ src/extension-host/service-lifecycle.ts | 75 +++++++++++ src/plugins/services.ts | 72 +---------- 3 files changed, 208 insertions(+), 66 deletions(-) create mode 100644 src/extension-host/service-lifecycle.test.ts create mode 100644 src/extension-host/service-lifecycle.ts diff --git a/src/extension-host/service-lifecycle.test.ts b/src/extension-host/service-lifecycle.test.ts new file mode 100644 index 00000000000..647afc589d3 --- /dev/null +++ b/src/extension-host/service-lifecycle.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import type { OpenClawPluginService, OpenClawPluginServiceContext } from "../plugins/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 { startExtensionHostServices } from "./service-lifecycle.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("startExtensionHostServices", () => { + 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 startExtensionHostServices({ + 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 and stop failures and continues", async () => { + const stopOk = vi.fn(); + const stopThrows = vi.fn(() => { + throw new Error("stop failed"); + }); + + const handle = await startExtensionHostServices({ + 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/extension-host/service-lifecycle.ts b/src/extension-host/service-lifecycle.ts new file mode 100644 index 00000000000..698e66ecd05 --- /dev/null +++ b/src/extension-host/service-lifecycle.ts @@ -0,0 +1,75 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { STATE_DIR } from "../config/paths.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { PluginRegistry } from "../plugins/registry.js"; +import type { OpenClawPluginServiceContext, PluginLogger } from "../plugins/types.js"; + +const log = createSubsystemLogger("plugins"); + +function createExtensionHostServiceLogger(): PluginLogger { + return { + info: (msg) => log.info(msg), + warn: (msg) => log.warn(msg), + error: (msg) => log.error(msg), + debug: (msg) => log.debug(msg), + }; +} + +function createExtensionHostServiceContext(params: { + config: OpenClawConfig; + workspaceDir?: string; +}): OpenClawPluginServiceContext { + return { + config: params.config, + workspaceDir: params.workspaceDir, + stateDir: STATE_DIR, + logger: createExtensionHostServiceLogger(), + }; +} + +export type ExtensionHostServicesHandle = { + stop: () => Promise; +}; + +export async function startExtensionHostServices(params: { + registry: PluginRegistry; + config: OpenClawConfig; + workspaceDir?: string; +}): Promise { + const running: Array<{ + id: string; + stop?: () => void | Promise; + }> = []; + const serviceContext = createExtensionHostServiceContext({ + config: params.config, + workspaceDir: params.workspaceDir, + }); + + for (const entry of params.registry.services) { + const service = entry.service; + try { + await service.start(serviceContext); + running.push({ + id: service.id, + stop: service.stop ? () => service.stop?.(serviceContext) : undefined, + }); + } catch (err) { + log.error(`plugin service failed (${service.id}): ${String(err)}`); + } + } + + return { + stop: async () => { + for (const entry of running.toReversed()) { + if (!entry.stop) { + continue; + } + try { + await entry.stop(); + } catch (err) { + log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`); + } + } + }, + }; +} diff --git a/src/plugins/services.ts b/src/plugins/services.ts index 751df4f8740..e21443998b6 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -1,75 +1,15 @@ import type { OpenClawConfig } from "../config/config.js"; -import { STATE_DIR } from "../config/paths.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + startExtensionHostServices, + type ExtensionHostServicesHandle, +} from "../extension-host/service-lifecycle.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; -}; +export type PluginServicesHandle = ExtensionHostServicesHandle; export async function startPluginServices(params: { registry: PluginRegistry; config: OpenClawConfig; workspaceDir?: string; }): Promise { - const running: Array<{ - 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(serviceContext); - running.push({ - id: service.id, - stop: service.stop ? () => service.stop?.(serviceContext) : undefined, - }); - } catch (err) { - log.error(`plugin service failed (${service.id}): ${String(err)}`); - } - } - - return { - stop: async () => { - for (const entry of running.toReversed()) { - if (!entry.stop) { - continue; - } - try { - await entry.stop(); - } catch (err) { - log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`); - } - } - }, - }; + return startExtensionHostServices(params); }