mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(status): distinguish runtime-loaded plugins from installed inventory (#97479)
* fix(status): distinguish runtime-loaded plugins from installed inventory /status plugins merged disk-scan plugin records with the active runtime registry, so the detailed Loaded: list could include plugins that were installed/config-enabled but never loaded at runtime. Record the runtime- loaded plugin ids on the health snapshot, carry them through the merge, and make Loaded: reflect runtime-confirmed plugins; show installed/discovered plugins that are not active as a neutral "Installed (not active)" inventory line rather than an error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(status): include pinned runtime registries in loaded ids Build runtimeLoadedPluginIds from all live runtime registry surfaces via collectLivePluginRegistries() (active plus any pinned channel / http-route / session-extension registry) and render Loaded: from that id set directly, so a plugin live only through a pinned surface still counts as loaded instead of being dropped or misreported as "Installed (not active)". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c0883a531d
commit
949b1af433
@@ -3,6 +3,7 @@ import { normalizeSortedUniqueStringEntries } from "@openclaw/normalization-core
|
||||
import { resolveCompatibleRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js";
|
||||
import type { PluginRegistry } from "./registry-types.js";
|
||||
import {
|
||||
collectLivePluginRegistries,
|
||||
getActivePluginChannelRegistry,
|
||||
getActivePluginHttpRouteRegistry,
|
||||
getActivePluginRegistry,
|
||||
@@ -15,6 +16,24 @@ export function getActiveRuntimePluginRegistry(): PluginRegistry | null {
|
||||
return getActivePluginRegistry();
|
||||
}
|
||||
|
||||
// Plugin ids confirmed loaded across every live runtime registry surface
|
||||
// (active plus any pinned http-route/channel/session-extension registry), via
|
||||
// the canonical collectLivePluginRegistries() set. A plugin can stay live via a
|
||||
// pinned surface that diverged from the active registry, so reading "loaded"
|
||||
// from the active registry alone would mislabel it. No-op when the surfaces are
|
||||
// synced to the active registry (the common case).
|
||||
export function listLoadedRuntimePluginIdsAcrossSurfaces(): string[] {
|
||||
const loaded: string[] = [];
|
||||
for (const registry of collectLivePluginRegistries()) {
|
||||
for (const plugin of registry.plugins ?? []) {
|
||||
if (plugin.status === "loaded") {
|
||||
loaded.push(plugin.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalizeSortedUniqueStringEntries(loaded);
|
||||
}
|
||||
|
||||
function normalizeRequiredPluginIds(ids?: readonly string[]): string[] | undefined {
|
||||
if (ids === undefined) {
|
||||
return undefined;
|
||||
|
||||
@@ -11,7 +11,13 @@ import {
|
||||
} from "../plugin-state/plugin-state-store.js";
|
||||
import { createRuntimeHealthRecordEnvelope } from "../plugin-state/runtime-health-store.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import {
|
||||
pinActivePluginChannelRegistry,
|
||||
pinActivePluginHttpRouteRegistry,
|
||||
pinActivePluginSessionExtensionRegistry,
|
||||
resetPluginRuntimeStateForTest,
|
||||
setActivePluginRegistry,
|
||||
} from "../plugins/runtime.js";
|
||||
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
|
||||
import { collectRuntimePluginHealthSnapshot } from "./status-plugin-health.runtime.js";
|
||||
|
||||
@@ -245,4 +251,83 @@ describe("runtime plugin health snapshot", () => {
|
||||
]);
|
||||
expect(resolveReadOnlyChannelPluginsForConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records only runtime status:loaded plugins as runtime-loaded", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.plugins.push(
|
||||
{ id: "runtime-ok", status: "loaded", enabled: true } as never,
|
||||
{ id: "runtime-broken", status: "error", enabled: true } as never,
|
||||
{ id: "runtime-off", status: "disabled", enabled: false } as never,
|
||||
);
|
||||
setActivePluginRegistry(registry, "runtime-loaded-ids", "default", "/tmp/ws");
|
||||
|
||||
expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual(["runtime-ok"]);
|
||||
});
|
||||
|
||||
it("includes loaded plugins from a pinned channel registry diverged from active", () => {
|
||||
const active = createEmptyPluginRegistry();
|
||||
active.plugins.push({ id: "active-ok", status: "loaded", enabled: true } as never);
|
||||
setActivePluginRegistry(active, "active", "default", "/tmp/ws");
|
||||
|
||||
const channel = createEmptyPluginRegistry();
|
||||
channel.plugins.push({ id: "channel-only", status: "loaded", enabled: true } as never);
|
||||
pinActivePluginChannelRegistry(channel);
|
||||
|
||||
expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([
|
||||
"active-ok",
|
||||
"channel-only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes loaded plugins from a pinned http-route registry diverged from active", () => {
|
||||
const active = createEmptyPluginRegistry();
|
||||
active.plugins.push({ id: "active-ok", status: "loaded", enabled: true } as never);
|
||||
setActivePluginRegistry(active, "active", "default", "/tmp/ws");
|
||||
|
||||
const httpRoute = createEmptyPluginRegistry();
|
||||
httpRoute.plugins.push({ id: "route-only", status: "loaded", enabled: true } as never);
|
||||
pinActivePluginHttpRouteRegistry(httpRoute);
|
||||
|
||||
expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([
|
||||
"active-ok",
|
||||
"route-only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes loaded plugins from a pinned session-extension registry diverged from active", () => {
|
||||
const active = createEmptyPluginRegistry();
|
||||
active.plugins.push({ id: "active-ok", status: "loaded", enabled: true } as never);
|
||||
setActivePluginRegistry(active, "active", "default", "/tmp/ws");
|
||||
|
||||
const sessionExtension = createEmptyPluginRegistry();
|
||||
sessionExtension.plugins.push({
|
||||
id: "session-only",
|
||||
status: "loaded",
|
||||
enabled: true,
|
||||
} as never);
|
||||
pinActivePluginSessionExtensionRegistry(sessionExtension);
|
||||
|
||||
expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([
|
||||
"active-ok",
|
||||
"session-only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes runtime-loaded ids shared across registry surfaces", () => {
|
||||
const active = createEmptyPluginRegistry();
|
||||
active.plugins.push({ id: "shared", status: "loaded", enabled: true } as never);
|
||||
setActivePluginRegistry(active, "active", "default", "/tmp/ws");
|
||||
|
||||
const channel = createEmptyPluginRegistry();
|
||||
channel.plugins.push(
|
||||
{ id: "shared", status: "loaded", enabled: true } as never,
|
||||
{ id: "channel-only", status: "loaded", enabled: true } as never,
|
||||
);
|
||||
pinActivePluginChannelRegistry(channel);
|
||||
|
||||
expect(collectRuntimePluginHealthSnapshot().runtimeLoadedPluginIds).toEqual([
|
||||
"channel-only",
|
||||
"shared",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,10 @@ import { resolveReadOnlyChannelPluginsForConfig } from "../channels/plugins/read
|
||||
// ordinary status tests do not eagerly load plugin registry internals.
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { listContextEngineQuarantines } from "../context-engine/registry.js";
|
||||
import { getActiveRuntimePluginRegistry } from "../plugins/active-runtime-registry.js";
|
||||
import {
|
||||
getActiveRuntimePluginRegistry,
|
||||
listLoadedRuntimePluginIdsAcrossSurfaces,
|
||||
} from "../plugins/active-runtime-registry.js";
|
||||
import {
|
||||
dedupeChannelPluginFailures,
|
||||
dedupePluginDiagnostics,
|
||||
@@ -157,6 +160,11 @@ export function collectRuntimePluginHealthSnapshot(): StatusPluginHealthSnapshot
|
||||
const registry = getActiveRuntimePluginRegistry();
|
||||
const diagnostics = (registry?.diagnostics ?? []).map(normalizeDiagnostic);
|
||||
const plugins = (registry?.plugins ?? []).map(normalizeSnapshotPlugin);
|
||||
// Confirmed runtime-loaded ids across all live registry surfaces (so a plugin
|
||||
// still live via a pinned channel/http-route registry counts) let detailed
|
||||
// status separate actually-loaded plugins from disk-scan inventory the merged
|
||||
// snapshot also marks "loaded".
|
||||
const runtimeLoadedPluginIds = listLoadedRuntimePluginIdsAcrossSurfaces();
|
||||
return {
|
||||
plugins,
|
||||
diagnostics,
|
||||
@@ -168,6 +176,7 @@ export function collectRuntimePluginHealthSnapshot(): StatusPluginHealthSnapshot
|
||||
channelPluginFailures: collectChannelPluginFailures({
|
||||
diagnostics,
|
||||
}),
|
||||
runtimeLoadedPluginIds,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -250,4 +250,77 @@ describe("plugin health status formatting", () => {
|
||||
expect(text).toContain("- WARN legacy-plugin [hook-only]: uses a compatibility shim");
|
||||
expect(text).toContain("Full inventory: /plugins list");
|
||||
});
|
||||
|
||||
it("separates runtime-loaded plugins from installed-but-not-active inventory", () => {
|
||||
const text = formatDetailedPluginHealth({
|
||||
plugins: [
|
||||
{ id: "runtime-ok", status: "loaded", enabled: true },
|
||||
// Disk scan marks this "loaded" from config, but the runtime registry
|
||||
// never loaded it (absent from runtimeLoadedPluginIds).
|
||||
{ id: "installed-idle", status: "loaded", enabled: true },
|
||||
{ id: "broken", status: "error", enabled: true, failurePhase: "load", error: "boom" },
|
||||
{ id: "off", status: "disabled", enabled: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
contextEngineQuarantines: [],
|
||||
runtimeLoadedPluginIds: ["runtime-ok"],
|
||||
});
|
||||
|
||||
expect(text).toContain("Loaded: 1 (runtime-ok)");
|
||||
expect(text).toContain("Installed (not active): 1 (installed-idle)");
|
||||
expect(text).toContain("Disabled: 1");
|
||||
// The errored plugin stays in Errors and the disabled plugin in Disabled;
|
||||
// neither leaks into the installed inventory line.
|
||||
expect(text).toContain("- broken [load]: boom");
|
||||
expect(text).not.toContain("Loaded: 2");
|
||||
});
|
||||
|
||||
it("falls back to status-loaded when runtime provenance is absent", () => {
|
||||
const text = formatDetailedPluginHealth({
|
||||
plugins: [
|
||||
{ id: "a", status: "loaded", enabled: true },
|
||||
{ id: "b", status: "loaded", enabled: true },
|
||||
],
|
||||
diagnostics: [],
|
||||
contextEngineQuarantines: [],
|
||||
});
|
||||
|
||||
expect(text).toContain("Loaded: 2 (a, b)");
|
||||
expect(text).not.toContain("Installed (not active):");
|
||||
});
|
||||
|
||||
it("keeps installed-only loaded plugins out of Loaded after merge", () => {
|
||||
const snapshot = mergeStatusPluginHealthSnapshots(
|
||||
{
|
||||
plugins: [{ id: "installed-idle", status: "loaded", enabled: true }],
|
||||
diagnostics: [],
|
||||
contextEngineQuarantines: [],
|
||||
},
|
||||
{
|
||||
plugins: [{ id: "runtime-ok", status: "loaded", enabled: true }],
|
||||
diagnostics: [],
|
||||
contextEngineQuarantines: [],
|
||||
runtimeLoadedPluginIds: ["runtime-ok"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(snapshot.runtimeLoadedPluginIds).toEqual(["runtime-ok"]);
|
||||
const text = formatDetailedPluginHealth(snapshot);
|
||||
expect(text).toContain("Loaded: 1 (runtime-ok)");
|
||||
expect(text).toContain("Installed (not active): 1 (installed-idle)");
|
||||
});
|
||||
|
||||
it("lists runtime-loaded plugins even when absent from the merged plugin records", () => {
|
||||
// A plugin live only via a pinned runtime surface is in runtimeLoadedPluginIds
|
||||
// but not in snapshot.plugins; it must still show under Loaded, not be dropped.
|
||||
const text = formatDetailedPluginHealth({
|
||||
plugins: [{ id: "active-ok", status: "loaded", enabled: true }],
|
||||
diagnostics: [],
|
||||
contextEngineQuarantines: [],
|
||||
runtimeLoadedPluginIds: ["active-ok", "pinned-only"],
|
||||
});
|
||||
|
||||
expect(text).toContain("Loaded: 2 (active-ok, pinned-only)");
|
||||
expect(text).not.toContain("Installed (not active):");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,12 @@ export type StatusPluginHealthSnapshot = {
|
||||
runtimeToolQuarantines?: RuntimeToolQuarantineRecord[];
|
||||
compatibilityNotices?: PluginCompatibilityHealthNotice[];
|
||||
channelPluginFailures?: ChannelPluginFailureRecord[];
|
||||
// Plugin ids confirmed loaded in the active runtime registry (status "loaded").
|
||||
// Lets detailed status separate runtime-loaded plugins from installed/discovered
|
||||
// inventory (the disk scan marks config-enabled plugins "loaded" before runtime
|
||||
// load). Absent on hand-built/compact snapshots, where detailed rendering falls
|
||||
// back to the merged status filter.
|
||||
runtimeLoadedPluginIds?: string[];
|
||||
};
|
||||
|
||||
/** Keeps the first record per key; later duplicates are dropped. */
|
||||
@@ -146,6 +152,9 @@ export function mergeStatusPluginHealthSnapshots(
|
||||
...(installed.compatibilityNotices ?? []),
|
||||
...(runtime.compatibilityNotices ?? []),
|
||||
]),
|
||||
// Runtime-loaded provenance is a runtime-side fact; the installed disk scan
|
||||
// cannot confirm it, so it never contributes here.
|
||||
runtimeLoadedPluginIds: runtime.runtimeLoadedPluginIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,10 +244,22 @@ function byLocale(left: string, right: string): number {
|
||||
}
|
||||
|
||||
export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot): string {
|
||||
const loaded = snapshot.plugins
|
||||
.filter((plugin) => plugin.status === "loaded")
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted(byLocale);
|
||||
const statusLoaded = snapshot.plugins.filter((plugin) => plugin.status === "loaded");
|
||||
// "Loaded" must mean runtime-confirmed loaded. When the snapshot carries runtime
|
||||
// provenance, render that authoritative id set directly (it spans all live
|
||||
// registry surfaces, so a plugin live only via a pinned surface still lists even
|
||||
// when it is absent from the merged records); installed-but-not-active is then
|
||||
// the status-loaded records the runtime did not load. Fall back to the raw
|
||||
// status when provenance is absent (hand-built/compact snapshots).
|
||||
const runtimeLoadedIds = snapshot.runtimeLoadedPluginIds;
|
||||
const runtimeLoaded = runtimeLoadedIds ? new Set(runtimeLoadedIds) : undefined;
|
||||
const loaded = (runtimeLoadedIds ?? statusLoaded.map((plugin) => plugin.id)).toSorted(byLocale);
|
||||
const installedNotActive = runtimeLoaded
|
||||
? statusLoaded
|
||||
.filter((plugin) => !runtimeLoaded.has(plugin.id))
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted(byLocale)
|
||||
: [];
|
||||
const disabled = snapshot.plugins.filter((plugin) => plugin.status === "disabled").length;
|
||||
const errors = snapshot.plugins
|
||||
.filter((plugin) => plugin.status === "error")
|
||||
@@ -266,6 +287,15 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot)
|
||||
`Disabled: ${disabled}`,
|
||||
];
|
||||
|
||||
if (installedNotActive.length > 0) {
|
||||
// Installed/discovered plugins not loaded in the runtime registry. Neutral
|
||||
// inventory, not an error: the gateway only starts the plugins its startup
|
||||
// plan requires, so configured-but-not-started is a normal steady state.
|
||||
lines.push(
|
||||
`Installed (not active): ${installedNotActive.length} (${formatPluginList(installedNotActive, 8)})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
lines.push(
|
||||
`Errors: ${errors.length}`,
|
||||
|
||||
Reference in New Issue
Block a user