Plugins: extract service lifecycle

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 17:25:32 +00:00
parent 278f1c4268
commit 6b24e65719
No known key found for this signature in database
3 changed files with 208 additions and 66 deletions

View File

@ -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<typeof startExtensionHostServices>[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<typeof startExtensionHostServices>[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();
});
});

View File

@ -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<void>;
};
export async function startExtensionHostServices(params: {
registry: PluginRegistry;
config: OpenClawConfig;
workspaceDir?: string;
}): Promise<ExtensionHostServicesHandle> {
const running: Array<{
id: string;
stop?: () => void | Promise<void>;
}> = [];
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)}`);
}
}
},
};
}

View File

@ -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<void>;
};
export type PluginServicesHandle = ExtensionHostServicesHandle;
export async function startPluginServices(params: {
registry: PluginRegistry;
config: OpenClawConfig;
workspaceDir?: string;
}): Promise<PluginServicesHandle> {
const running: Array<{
id: string;
stop?: () => void | Promise<void>;
}> = [];
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);
}