feat: route internal events through a shared plugin event bus

Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
This commit is contained in:
LukeGus committed 2026-09-21 07:19:01 -05:00
1 parent 0a771c9600
commit 1393fa7581
9 files changed
+433 -52

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
describe("plugin event bus", () => {
afterEach(() => {
pluginEvents.clear();
vi.clearAllMocks();
});
it("delivers a payload to every subscriber of a topic", () => {
const first = vi.fn();
const second = vi.fn();
pluginEvents.on("topic.a", first);
pluginEvents.on("topic.a", second);
pluginEvents.emit("topic.a", { value: 1 });
expect(first).toHaveBeenCalledWith({ value: 1 });
expect(second).toHaveBeenCalledWith({ value: 1 });
});
it("does not deliver across topics", () => {
const listener = vi.fn();
pluginEvents.on("topic.a", listener);
pluginEvents.emit("topic.b", {});
expect(listener).not.toHaveBeenCalled();
});
it("emitting a topic with no subscribers is a no-op", () => {
expect(() => pluginEvents.emit("nobody.listening", {})).not.toThrow();
});
it("unsubscribes and cleans up the empty topic", () => {
const listener = vi.fn();
const off = pluginEvents.on("topic.a", listener);
expect(pluginEvents.listenerCount("topic.a")).toBe(1);
off();
expect(pluginEvents.listenerCount("topic.a")).toBe(0);
pluginEvents.emit("topic.a", {});
expect(listener).not.toHaveBeenCalled();
});
it("a throwing subscriber does not stop the others or the caller", async () => {
const after = vi.fn();
pluginEvents.on("topic.a", () => {
throw new Error("subscriber exploded");
});
pluginEvents.on("topic.a", after);
expect(() => pluginEvents.emit("topic.a", {})).not.toThrow();
expect(after).toHaveBeenCalled();
const { pluginLogger } = await import("../../utils/logger.js");
expect(pluginLogger.error).toHaveBeenCalled();
});
it("a rejecting async subscriber is reported, not left unhandled", async () => {
pluginEvents.on("topic.a", async () => {
throw new Error("async subscriber exploded");
});
expect(() => pluginEvents.emit("topic.a", {})).not.toThrow();
await new Promise((resolve) => setImmediate(resolve));
const { pluginLogger } = await import("../../utils/logger.js");
expect(pluginLogger.error).toHaveBeenCalled();
});
it("a subscriber added during an emit does not receive that same emit", () => {
const late = vi.fn();
pluginEvents.on("topic.a", () => {
pluginEvents.on("topic.a", late);
});
pluginEvents.emit("topic.a", {});
expect(late).not.toHaveBeenCalled();
pluginEvents.emit("topic.a", {});
expect(late).toHaveBeenCalledTimes(1);
});
it("exposes the internal topic names the server publishes", () => {
expect(TOPICS.hostSessionStatus).toBe("host.session.status");
expect(TOPICS.internalEvent).toBe("internal.event");
expect(TOPICS.hostMetrics).toBe("host.metrics");
expect(TOPICS.hostStatus).toBe("host.status");
expect(TOPICS.hostHealthCheck).toBe("host.health_check");
});
});
describe("hosts publishers route through the bus", () => {
// No vi.resetModules() here: the bus is a module singleton, and resetting
// would hand these imports a different instance than the one subscribed to.
afterEach(() => {
pluginEvents.clear();
});
it("notifyAutomationInternalEvent emits internal.event", async () => {
const { notifyAutomationInternalEvent } =
await import("../../hosts/automation-events.js");
const listener = vi.fn();
pluginEvents.on(TOPICS.internalEvent, listener);
notifyAutomationInternalEvent("host_deleted", "user-1", 42, { a: 1 });
expect(listener).toHaveBeenCalledWith({
event: "host_deleted",
userId: "user-1",
hostId: 42,
details: { a: 1 },
});
});
it("notifyAutomationInternalEvent ignores a missing userId", async () => {
const { notifyAutomationInternalEvent } =
await import("../../hosts/automation-events.js");
const listener = vi.fn();
pluginEvents.on(TOPICS.internalEvent, listener);
notifyAutomationInternalEvent("host_deleted", "");
expect(listener).not.toHaveBeenCalled();
});
it("notifyAutomationStatus and notifyAutomationMetrics emit their topics", async () => {
const { notifyAutomationStatus, notifyAutomationMetrics } =
await import("../../hosts/metrics/automation-bridge.js");
const status = vi.fn();
const metrics = vi.fn();
pluginEvents.on(TOPICS.hostStatus, status);
pluginEvents.on(TOPICS.hostMetrics, metrics);
notifyAutomationStatus(42, "user-1", true);
notifyAutomationMetrics(42, "user-1", { cpu: 10 } as never);
expect(status).toHaveBeenCalledWith({
hostId: 42,
ownerUserId: "user-1",
online: true,
});
expect(metrics).toHaveBeenCalledWith({
hostId: 42,
ownerUserId: "user-1",
metrics: { cpu: 10 },
});
});
it("hostSessionStatus publishes host.session.status on the refcount edges", async () => {
const { HostSessionStatus } =
await import("../../hosts/host-session-status.js");
const seen: unknown[] = [];
pluginEvents.on(TOPICS.hostSessionStatus, (p) => seen.push(p));
const status = new HostSessionStatus();
const releaseFirst = status.register(7);
const releaseSecond = status.register(7);
// Only the 0 -> 1 edge publishes.
expect(seen).toEqual([{ hostId: 7, online: true }]);
releaseFirst();
expect(seen).toHaveLength(1);
// ... and only the 1 -> 0 edge.
releaseSecond();
expect(seen).toEqual([
{ hostId: 7, online: true },
{ hostId: 7, online: false },
]);
});
});