mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-14 22:54:01 -06:00
357087b9c7
* refactor(runtime): own ambient state lifecycle cleanup * chore(plugin-sdk): refresh additive API baseline * fix(runtime): restore agent wait listener lifecycle Keep agent-job data process-shared while preserving module-local event subscription ownership. Ratchet the public wildcard budget down after replacing text-runtime's wildcard singleton export with named exports.
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
// Coordinates active plugin runtime registries and event hooks.
|
|
import { onAgentEvent } from "../infra/agent-events.js";
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import { drainGlobalSingletonLifecycleState } from "../shared/global-singleton.js";
|
|
import {
|
|
clearPluginHostRuntimeState,
|
|
dispatchPluginAgentEventSubscriptions,
|
|
} from "./host-hook-runtime.js";
|
|
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
|
|
import { settlePreparedMessageToolCatalog } from "./prepared-message-tool-catalog.js";
|
|
import { createEmptyPluginRegistry } from "./registry-empty.js";
|
|
import { markPluginRegistryActive, markPluginRegistryRetired } from "./registry-lifecycle.js";
|
|
import type { PluginRegistry } from "./registry-types.js";
|
|
import { getActivePluginChannelRegistrySnapshotFromState } from "./runtime-channel-state.js";
|
|
import { PLUGIN_REGISTRY_STATE, type RegistryState } from "./runtime-state.js";
|
|
import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js";
|
|
|
|
const log = createSubsystemLogger("plugins/runtime");
|
|
|
|
function asPluginRegistry(registry: RegistryState["activeRegistry"]): PluginRegistry | null {
|
|
return registry;
|
|
}
|
|
|
|
const state: RegistryState = (() => {
|
|
const globalState = globalThis as typeof globalThis & {
|
|
[PLUGIN_REGISTRY_STATE]?: RegistryState;
|
|
};
|
|
let registryState = globalState[PLUGIN_REGISTRY_STATE];
|
|
if (!registryState) {
|
|
registryState = {
|
|
activeRegistry: null,
|
|
activeVersion: 0,
|
|
agentEventBridgeUnsubscribe: undefined,
|
|
key: null,
|
|
workspaceDir: null,
|
|
runtimeSubagentMode: "default",
|
|
importedPluginIds: new Set<string>(),
|
|
};
|
|
globalState[PLUGIN_REGISTRY_STATE] = registryState;
|
|
}
|
|
return registryState;
|
|
})();
|
|
|
|
function registryHasPluginHostCleanupWork(registry: PluginRegistry | null): boolean {
|
|
if (!registry) {
|
|
return false;
|
|
}
|
|
return (
|
|
registry.plugins.some((plugin) => plugin.status === "loaded") ||
|
|
registry.sessionExtensions.length > 0 ||
|
|
registry.runtimeLifecycles.length > 0 ||
|
|
registry.agentEventSubscriptions.length > 0 ||
|
|
registry.sessionSchedulerJobs.length > 0
|
|
);
|
|
}
|
|
|
|
function isRegistryLive(registry: PluginRegistry): boolean {
|
|
return state.activeRegistry === registry;
|
|
}
|
|
|
|
async function cleanupPreviousPluginHostRegistry(params: {
|
|
previousRegistry: PluginRegistry;
|
|
}): Promise<void> {
|
|
const [{ getRuntimeConfig }, { cleanupReplacedPluginHostRegistry }] = await Promise.all([
|
|
import("../config/config.js"),
|
|
import("./host-hook-cleanup.js"),
|
|
]);
|
|
const nextRegistry = asPluginRegistry(state.activeRegistry);
|
|
if (nextRegistry === params.previousRegistry) {
|
|
return;
|
|
}
|
|
// Async cleanup must not clear state for a registry that has been restored
|
|
// active, but later swaps should not strand cleanup for the retiring registry.
|
|
const shouldCleanup = () => state.activeRegistry !== params.previousRegistry;
|
|
await cleanupReplacedPluginHostRegistry({
|
|
cfg: getRuntimeConfig(),
|
|
previousRegistry: params.previousRegistry,
|
|
nextRegistry,
|
|
shouldCleanup,
|
|
});
|
|
}
|
|
|
|
function cleanupRetiredPluginHostRegistry(previousRegistry: PluginRegistry): void {
|
|
if (!registryHasPluginHostCleanupWork(previousRegistry)) {
|
|
return;
|
|
}
|
|
void cleanupPreviousPluginHostRegistry({
|
|
previousRegistry,
|
|
}).catch((error: unknown) => {
|
|
log.warn(`plugin host registry cleanup failed: ${String(error)}`);
|
|
});
|
|
}
|
|
|
|
function retirePluginRegistryIfUnused(registry: PluginRegistry | null): boolean {
|
|
if (!registry || isRegistryLive(registry)) {
|
|
return false;
|
|
}
|
|
markPluginRegistryRetired(registry);
|
|
return true;
|
|
}
|
|
|
|
function syncPluginAgentEventBridge(): void {
|
|
state.agentEventBridgeUnsubscribe?.();
|
|
state.agentEventBridgeUnsubscribe = undefined;
|
|
if (!state.activeRegistry) {
|
|
return;
|
|
}
|
|
state.agentEventBridgeUnsubscribe = onAgentEvent((event) => {
|
|
const registry = asPluginRegistry(state.activeRegistry);
|
|
if (registry) {
|
|
dispatchPluginAgentEventSubscriptions({ registry, event });
|
|
}
|
|
});
|
|
}
|
|
|
|
export function recordImportedPluginId(pluginId: string): void {
|
|
state.importedPluginIds.add(pluginId);
|
|
}
|
|
|
|
export function setActivePluginRegistry(
|
|
registry: PluginRegistry,
|
|
cacheKey?: string,
|
|
runtimeSubagentMode: "default" | "explicit" | "gateway-bindable" = "default",
|
|
workspaceDir?: string,
|
|
) {
|
|
installActivePluginRegistry({
|
|
registry,
|
|
key: cacheKey ?? null,
|
|
runtimeSubagentMode,
|
|
workspaceDir: workspaceDir ?? null,
|
|
});
|
|
}
|
|
|
|
export function stageActivePluginRegistry(
|
|
registry: PluginRegistry,
|
|
cacheKey: string | null,
|
|
runtimeSubagentMode: RegistryState["runtimeSubagentMode"],
|
|
workspaceDir?: string,
|
|
): void {
|
|
installActivePluginRegistry({
|
|
registry,
|
|
key: cacheKey,
|
|
runtimeSubagentMode,
|
|
workspaceDir: workspaceDir ?? null,
|
|
retirePrevious: false,
|
|
});
|
|
}
|
|
|
|
export function commitStagedPluginRegistry(
|
|
previousRegistry: PluginRegistry | null,
|
|
registry: PluginRegistry,
|
|
): void {
|
|
if (state.activeRegistry !== registry || !retirePluginRegistryIfUnused(previousRegistry)) {
|
|
return;
|
|
}
|
|
cleanupRetiredPluginHostRegistry(previousRegistry!);
|
|
}
|
|
|
|
export function captureActivePluginRegistrySnapshot() {
|
|
return {
|
|
activeRegistry: state.activeRegistry,
|
|
key: state.key,
|
|
runtimeSubagentMode: state.runtimeSubagentMode,
|
|
workspaceDir: state.workspaceDir,
|
|
};
|
|
}
|
|
|
|
export function restoreActivePluginRegistrySnapshot(
|
|
snapshot: ReturnType<typeof captureActivePluginRegistrySnapshot>,
|
|
): void {
|
|
installActivePluginRegistry({
|
|
registry: snapshot.activeRegistry,
|
|
key: snapshot.key,
|
|
runtimeSubagentMode: snapshot.runtimeSubagentMode,
|
|
workspaceDir: snapshot.workspaceDir,
|
|
});
|
|
}
|
|
|
|
function installActivePluginRegistry(params: {
|
|
registry: PluginRegistry | null;
|
|
key: string | null;
|
|
runtimeSubagentMode: RegistryState["runtimeSubagentMode"];
|
|
workspaceDir: string | null;
|
|
retirePrevious?: boolean;
|
|
}): void {
|
|
const previousRegistry = asPluginRegistry(state.activeRegistry);
|
|
state.activeRegistry = params.registry;
|
|
markPluginRegistryActive(params.registry);
|
|
state.activeVersion += 1;
|
|
if (params.registry) {
|
|
settlePreparedMessageToolCatalog(params.registry, state.activeVersion);
|
|
} else {
|
|
settlePreparedMessageToolCatalog();
|
|
}
|
|
state.key = params.key;
|
|
state.workspaceDir = params.workspaceDir;
|
|
state.runtimeSubagentMode = params.runtimeSubagentMode;
|
|
syncPluginAgentEventBridge();
|
|
if (
|
|
params.retirePrevious === false ||
|
|
!previousRegistry ||
|
|
previousRegistry === params.registry
|
|
) {
|
|
return;
|
|
}
|
|
if (!retirePluginRegistryIfUnused(previousRegistry)) {
|
|
return;
|
|
}
|
|
cleanupRetiredPluginHostRegistry(previousRegistry);
|
|
}
|
|
|
|
export function getActivePluginRegistry(): PluginRegistry | null {
|
|
return asPluginRegistry(state.activeRegistry);
|
|
}
|
|
|
|
export function getActivePluginRegistryWorkspaceDir(): string | undefined {
|
|
return state.workspaceDir ?? undefined;
|
|
}
|
|
|
|
export function requireActivePluginRegistry(): PluginRegistry {
|
|
if (state.registrationContext) {
|
|
return state.registrationContext.registry;
|
|
}
|
|
const scopedRegistry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry;
|
|
if (scopedRegistry) {
|
|
return scopedRegistry;
|
|
}
|
|
if (!state.activeRegistry) {
|
|
state.activeRegistry = createEmptyPluginRegistry();
|
|
markPluginRegistryActive(state.activeRegistry);
|
|
state.activeVersion += 1;
|
|
settlePreparedMessageToolCatalog(state.activeRegistry, state.activeVersion);
|
|
syncPluginAgentEventBridge();
|
|
}
|
|
return asPluginRegistry(state.activeRegistry)!;
|
|
}
|
|
|
|
/** Binds unchanged direct SDK facades to the registry currently running synchronous register(). */
|
|
export function withPluginRegistrationContext<T>(
|
|
registry: PluginRegistry,
|
|
pluginId: string,
|
|
run: () => T,
|
|
): T {
|
|
const previous = state.registrationContext;
|
|
state.registrationContext = { registry, pluginId };
|
|
try {
|
|
return run();
|
|
} finally {
|
|
state.registrationContext = previous;
|
|
}
|
|
}
|
|
|
|
export function getPluginRegistrationContext() {
|
|
return state.registrationContext;
|
|
}
|
|
|
|
/** Keeps direct registration facades owned by the plugin whose synchronous register() is running. */
|
|
export function resolveDirectPluginRegistrationOwner(ownerPluginId?: string): string | undefined {
|
|
return state.registrationContext?.pluginId ?? ownerPluginId;
|
|
}
|
|
|
|
/** A failed plugin must not displace an earlier plugin's builder-local contribution. */
|
|
export function assertDirectPluginRegistrationReplacement(
|
|
existingOwnerPluginId: string | undefined,
|
|
capability: string,
|
|
): void {
|
|
const pluginId = state.registrationContext?.pluginId;
|
|
if (pluginId && existingOwnerPluginId !== pluginId) {
|
|
throw new Error(`${capability} already registered by ${existingOwnerPluginId || "core"}`);
|
|
}
|
|
}
|
|
|
|
export function getActivePluginHttpRouteRegistry(): PluginRegistry | null {
|
|
return asPluginRegistry(state.activeRegistry);
|
|
}
|
|
|
|
export function getActivePluginHttpRouteRegistryVersion(): number {
|
|
return state.activeVersion;
|
|
}
|
|
|
|
export function requireActivePluginHttpRouteRegistry(): PluginRegistry {
|
|
const existing = getActivePluginHttpRouteRegistry();
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
return requireActivePluginRegistry();
|
|
}
|
|
|
|
export function getActivePluginChannelRegistry(): PluginRegistry | null {
|
|
return getActivePluginChannelRegistrySnapshotFromState().registry as PluginRegistry | null;
|
|
}
|
|
|
|
export function getActivePluginChannelRegistryVersion(): number {
|
|
return getActivePluginChannelRegistrySnapshotFromState().version;
|
|
}
|
|
|
|
export function getActivePluginGatewayCommandRegistry(): PluginRegistry | null {
|
|
return asPluginRegistry(state.activeRegistry);
|
|
}
|
|
|
|
export function getActivePluginGatewayNodePolicyRegistry(): PluginRegistry | null {
|
|
return asPluginRegistry(state.activeRegistry);
|
|
}
|
|
|
|
export function requireActivePluginChannelRegistry(): PluginRegistry {
|
|
const existing = getActivePluginChannelRegistry();
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
return requireActivePluginRegistry();
|
|
}
|
|
|
|
export function getActivePluginSessionExtensionRegistry(): PluginRegistry | null {
|
|
return asPluginRegistry(state.activeRegistry);
|
|
}
|
|
|
|
export function getActivePluginRegistryKey(): string | null {
|
|
return state.key;
|
|
}
|
|
|
|
export function getActivePluginRuntimeSubagentMode(): "default" | "explicit" | "gateway-bindable" {
|
|
return state.runtimeSubagentMode;
|
|
}
|
|
|
|
export function getActivePluginRegistryVersion(): number {
|
|
return state.activeVersion;
|
|
}
|
|
|
|
function collectLoadedPluginIds(
|
|
registry: PluginRegistry | null | undefined,
|
|
ids: Set<string>,
|
|
): void {
|
|
if (!registry) {
|
|
return;
|
|
}
|
|
for (const plugin of registry.plugins) {
|
|
if (plugin.status === "loaded" && plugin.format !== "bundle") {
|
|
ids.add(plugin.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns plugin ids that were imported by plugin runtime or registry loading in
|
|
* the current process.
|
|
*
|
|
* This is a process-level view, not a fresh import trace: cached registry reuse
|
|
* still counts because the plugin code was loaded earlier in this process.
|
|
* Explicit loader import tracking covers plugins that were imported but later
|
|
* ended in an error state during registration.
|
|
* Bundle-format plugins are excluded because they can be "loaded" from metadata
|
|
* without importing any JS entrypoint.
|
|
*/
|
|
export function listImportedRuntimePluginIds(): string[] {
|
|
const imported = new Set(state.importedPluginIds);
|
|
collectLoadedPluginIds(asPluginRegistry(state.activeRegistry), imported);
|
|
return [...imported].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function clearActivePluginRegistryState(): PluginRegistry | null {
|
|
const previousRegistry = asPluginRegistry(state.activeRegistry);
|
|
state.activeRegistry = null;
|
|
state.activeVersion += 1;
|
|
state.key = null;
|
|
state.workspaceDir = null;
|
|
state.runtimeSubagentMode = "default";
|
|
settlePreparedMessageToolCatalog();
|
|
syncPluginAgentEventBridge();
|
|
if (previousRegistry) {
|
|
markPluginRegistryRetired(previousRegistry);
|
|
}
|
|
return previousRegistry;
|
|
}
|
|
|
|
export async function clearActivePluginRegistry(): Promise<void> {
|
|
const previousRegistry = clearActivePluginRegistryState();
|
|
try {
|
|
if (registryHasPluginHostCleanupWork(previousRegistry)) {
|
|
await cleanupPreviousPluginHostRegistry({ previousRegistry: previousRegistry! });
|
|
}
|
|
} finally {
|
|
try {
|
|
await drainGlobalSingletonLifecycleState("plugin-registry");
|
|
} finally {
|
|
clearPluginHostRuntimeState();
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resetPluginRuntimeStateForTest(): void {
|
|
state.registrationContext = undefined;
|
|
clearActivePluginRegistryState();
|
|
state.importedPluginIds.clear();
|
|
void drainGlobalSingletonLifecycleState("plugin-registry");
|
|
// Keep the synchronous test reset aligned with clearActivePluginRegistry.
|
|
clearPluginHostRuntimeState();
|
|
clearPluginMetadataLifecycleCaches();
|
|
}
|