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

+23 -15
View File
@@ -1,17 +1,24 @@
/**
* One-line hand-off from any hosts feature to the automations engine for a
* generic named event (not tied to metrics polling).
* One-line hand-off from any hosts feature to anything listening for a generic
* named event (not tied to metrics polling).
*
* Fire-and-forget and imported lazily: a failure in the automations layer
* must never disturb the caller, and a static import would create a cycle
* (automations reads repositories, which several hosts modules also pull in).
*
* PLUGIN-EVENT: this whole module is the "publish an internal event" half of
* the phase-2 ctx.events bus. Once that exists, callers should emit onto
* ctx.events instead of calling this directly, and the automations engine
* subscribes there rather than being imported ad hoc.
* This now publishes onto the ctx.events bus rather than reaching into the
* automations engine directly. The two properties callers relied on are
* unchanged: it is fire-and-forget, and it creates no static edge to the
* automations layer (which reads repositories that several hosts modules also
* pull in, so a static import would close a cycle). The automations engine
* subscribes to the bus at start-up instead -- see automations/triggers.ts.
*/
import { pluginEvents, TOPICS } from "../plugins/events.js";
export interface InternalEventPayload {
event: string;
userId: string;
hostId?: number;
details?: Record<string, unknown>;
}
export function notifyAutomationInternalEvent(
event: string,
userId: string,
@@ -19,9 +26,10 @@ export function notifyAutomationInternalEvent(
details?: Record<string, unknown>,
): void {
if (!userId) return;
import("../automations/triggers.js")
.then((triggers) =>
triggers.onInternalEvent({ event, userId, hostId, details }),
)
.catch(() => {});
pluginEvents.emit(TOPICS.internalEvent, {
event,
userId,
hostId,
details,
} satisfies InternalEventPayload);
}
+13
View File
@@ -1,5 +1,12 @@
import { pluginEvents, TOPICS } from "../plugins/events.js";
type HostSessionStatusListener = (hostId: number, online: boolean) => void;
export interface HostSessionStatusPayload {
hostId: number;
online: boolean;
}
export class HostSessionStatus {
private counts = new Map<number, number>();
private listeners = new Set<HostSessionStatusListener>();
@@ -32,6 +39,12 @@ export class HostSessionStatus {
private emit(hostId: number, online: boolean): void {
for (const listener of this.listeners) listener(hostId, online);
// Also published as a topic so plugins (and anything else on the bus) can
// observe session status without holding a reference to this singleton.
pluginEvents.emit(TOPICS.hostSessionStatus, {
hostId,
online,
} satisfies HostSessionStatusPayload);
}
}
+43 -16
View File
@@ -1,27 +1,50 @@
/**
* One-line hand-off from the metrics poller to the automations engine.
* One-line hand-off from the metrics poller onto the ctx.events bus.
*
* The poller is already a very large module, so the hooks it calls live here
* instead. Everything is fire-and-forget and imported lazily: a failure in the
* automations layer must never disturb metric collection, and a static import
* would create a cycle (automations reads repositories, which the metrics
* module also pulls in).
* instead. Everything is fire-and-forget: a failure in a subscriber must never
* disturb metric collection, and publishing onto the bus creates no static edge
* to the automations layer (which reads repositories the metrics module also
* pulls in).
*
* For the generic (non-metrics) internal event notifier, see
* `hosts/automation-events.ts` -- that one is shared across features.
*/
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import type { MetricsSnapshot } from "../../automations/conditions.js";
export interface HostMetricsPayload {
hostId: number;
ownerUserId: string;
metrics: MetricsSnapshot;
}
export interface HostStatusPayload {
hostId: number;
ownerUserId: string;
online: boolean;
}
export interface HostHealthCheckPayload {
hostId: number;
userId: string;
checkId: string;
ok: boolean;
detail?: string;
}
export function notifyAutomationMetrics(
hostId: number,
ownerUserId: string,
metrics: MetricsSnapshot,
): void {
if (!ownerUserId) return;
import("../../automations/triggers.js")
.then((triggers) => triggers.onMetrics({ hostId, ownerUserId, metrics }))
.catch(() => {});
pluginEvents.emit(TOPICS.hostMetrics, {
hostId,
ownerUserId,
metrics,
} satisfies HostMetricsPayload);
}
export function notifyAutomationStatus(
@@ -30,9 +53,11 @@ export function notifyAutomationStatus(
online: boolean,
): void {
if (!ownerUserId) return;
import("../../automations/triggers.js")
.then((triggers) => triggers.onStatus({ hostId, ownerUserId, online }))
.catch(() => {});
pluginEvents.emit(TOPICS.hostStatus, {
hostId,
ownerUserId,
online,
} satisfies HostStatusPayload);
}
export function notifyAutomationHealthCheck(
@@ -43,9 +68,11 @@ export function notifyAutomationHealthCheck(
detail?: string,
): void {
if (!userId) return;
import("../../automations/triggers.js")
.then((triggers) =>
triggers.onHealthCheck({ hostId, userId, checkId, ok, detail }),
)
.catch(() => {});
pluginEvents.emit(TOPICS.hostHealthCheck, {
hostId,
userId,
checkId,
ok,
detail,
} satisfies HostHealthCheckPayload);
}
+8 -6
View File
@@ -61,8 +61,8 @@ import { registerHostMetricsHistoryRoutes } from "./history-routes.js";
import { registerProxmoxStatsRoutes } from "./proxmox-stats-routes.js";
import { registerProxmoxStatsHistoryRoutes } from "./proxmox-stats-history-routes.js";
import { ProxmoxPollingManager } from "./proxmox-stats-polling.js";
// PLUGIN-EVENT: terminal session online/offline -> phase-2 ctx.events "host.session.status" topic
import { hostSessionStatus } from "../host-session-status.js";
import type { HostSessionStatusPayload } from "../host-session-status.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import { AlertEngine } from "./alert-engine.js";
import {
notifyAutomationMetrics,
@@ -225,10 +225,12 @@ class PollingManager {
private unsubscribeHostSessionStatus: () => void;
constructor() {
// PLUGIN-EVENT: subscribe to ctx.events.on("host.session.status", ...) once the
// phase-2 event bus exists, instead of the hostSessionStatus singleton directly.
this.unsubscribeHostSessionStatus = hostSessionStatus.subscribe(
(hostId, online) => this.setTerminalSessionOnline(hostId, online),
this.unsubscribeHostSessionStatus = pluginEvents.on(
TOPICS.hostSessionStatus,
(payload) => {
const { hostId, online } = payload as HostSessionStatusPayload;
this.setTerminalSessionOnline(hostId, online);
},
);
this.viewerCleanupInterval = setInterval(() => {
this.cleanupInactiveViewers();