From 888f399499c446a711832142b87a406cdf4cdc88 Mon Sep 17 00:00:00 2001 From: Masato Hoshino Date: Tue, 30 Jun 2026 02:46:36 +0900 Subject: [PATCH] fix(status): surface should-run plugin drift (#97878) Co-authored-by: Claude Opus 4.8 --- .../plugin-activation-runtime-config.test.ts | 62 +++++++ .../plugin-activation-runtime-config.ts | 27 +++ src/gateway/server-startup-plugins.ts | 19 +- .../status-plugin-health.installed.test.ts | 168 ++++++++++++++++++ src/status/status-plugin-health.runtime.ts | 49 ++++- src/status/status-plugin-health.test.ts | 72 ++++++++ src/status/status-plugin-health.ts | 36 ++++ 7 files changed, 421 insertions(+), 12 deletions(-) create mode 100644 src/gateway/plugin-activation-runtime-config.test.ts create mode 100644 src/status/status-plugin-health.installed.test.ts diff --git a/src/gateway/plugin-activation-runtime-config.test.ts b/src/gateway/plugin-activation-runtime-config.test.ts new file mode 100644 index 00000000000..e91fdcbbdf9 --- /dev/null +++ b/src/gateway/plugin-activation-runtime-config.test.ts @@ -0,0 +1,62 @@ +// Covers the shared startup-plan activation config assembly used by both gateway boot +// (prepareGatewayPluginBootstrap) and the /status plugins should-run drift check. +import { describe, expect, it, vi } from "vitest"; +import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveGatewayStartupPluginActivationConfig } from "./plugin-activation-runtime-config.js"; + +vi.mock("../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: vi.fn(), +})); + +const applyPluginAutoEnableMock = vi.mocked(applyPluginAutoEnable); + +describe("resolveGatewayStartupPluginActivationConfig", () => { + it("auto-enables the source config, then merges activation into the runtime config", () => { + const runtimeConfig = { + plugins: { entries: { keep: { enabled: true, runtimeOnly: 1 } } }, + } as unknown as OpenClawConfig; + const sourceConfig = { + plugins: { entries: { keep: { enabled: true } } }, + } as unknown as OpenClawConfig; + // Auto-enable runs against the source config and yields an activation config that + // enables an extra plugin; only enable/allow surfaces should carry into runtime config. + applyPluginAutoEnableMock.mockReturnValue({ + config: { plugins: { entries: { added: { enabled: true } } } }, + } as unknown as ReturnType); + + const result = resolveGatewayStartupPluginActivationConfig({ + runtimeConfig, + activationSourceConfig: sourceConfig, + env: {} as NodeJS.ProcessEnv, + }); + + // Activation is computed from the operator source config, not the runtime config. + expect(applyPluginAutoEnableMock).toHaveBeenCalledWith( + expect.objectContaining({ config: sourceConfig }), + ); + // Runtime-only field is preserved; the auto-enabled activation entry is merged in. + expect(result.plugins?.entries?.keep).toEqual({ enabled: true, runtimeOnly: 1 }); + expect(result.plugins?.entries?.added).toEqual({ enabled: true }); + }); + + it("passes manifestRegistry and discovery through to auto-enable when provided", () => { + applyPluginAutoEnableMock.mockReturnValue({ + config: {}, + } as unknown as ReturnType); + const manifestRegistry = { plugins: [] } as never; + const discovery = { candidates: [] } as never; + + resolveGatewayStartupPluginActivationConfig({ + runtimeConfig: {} as OpenClawConfig, + activationSourceConfig: {} as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + manifestRegistry, + discovery, + }); + + expect(applyPluginAutoEnableMock).toHaveBeenCalledWith( + expect.objectContaining({ manifestRegistry, discovery }), + ); + }); +}); diff --git a/src/gateway/plugin-activation-runtime-config.ts b/src/gateway/plugin-activation-runtime-config.ts index 877aae7a8db..c0b2e5cb0c5 100644 --- a/src/gateway/plugin-activation-runtime-config.ts +++ b/src/gateway/plugin-activation-runtime-config.ts @@ -1,6 +1,9 @@ // Plugin/channel activation config merge helpers. // Carries activation enablement into runtime config without copying stale state. +import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginDiscoveryResult } from "../plugins/discovery.js"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { isRecord } from "../utils.js"; // Activation config carries only operator-controlled enable/allow surfaces into @@ -109,3 +112,27 @@ export function mergeActivationSectionsIntoRuntimeConfig(params: { runtimeConfig: mergeChannelActivationSections(params), }); } + +// Resolves the effective plugin config the gateway startup *plan* is built from: +// auto-enable the operator activation source, then merge those activation sections into +// the runtime config (so runtime/defaulted fields survive). This is the exact assembly +// `prepareGatewayPluginBootstrap` uses (non-minimal branch); sharing it keeps any consumer +// that recomputes the startup plan — notably the `/status plugins` should-run drift check — +// from drifting away from real gateway boot. Behavior-preserving extraction only. +export function resolveGatewayStartupPluginActivationConfig(params: { + runtimeConfig: OpenClawConfig; + activationSourceConfig: OpenClawConfig; + env: NodeJS.ProcessEnv; + manifestRegistry?: PluginManifestRegistry; + discovery?: PluginDiscoveryResult; +}): OpenClawConfig { + return mergeActivationSectionsIntoRuntimeConfig({ + runtimeConfig: params.runtimeConfig, + activationConfig: applyPluginAutoEnable({ + config: params.activationSourceConfig, + env: params.env, + ...(params.manifestRegistry ? { manifestRegistry: params.manifestRegistry } : {}), + discovery: params.discovery, + }).config, + }); +} diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index 65bc93ff7ca..f8e817e8de8 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -2,7 +2,6 @@ // Runs startup maintenance, loads plugin runtime, and prepares advertised methods. import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { initSubagentRegistry } from "../agents/subagent-registry.js"; -import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { collectUnregisteredConfiguredMemoryEmbeddingProviders } from "../plugins/channel-plugin-ids.js"; import { listRegisteredEmbeddingProviders } from "../plugins/embedding-providers.js"; @@ -12,7 +11,7 @@ import type { PluginRegistry, PluginRegistryParams } from "../plugins/registry-t import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; import { listCoreGatewayMethodNames } from "./methods/core-descriptors.js"; -import { mergeActivationSectionsIntoRuntimeConfig } from "./plugin-activation-runtime-config.js"; +import { resolveGatewayStartupPluginActivationConfig } from "./plugin-activation-runtime-config.js"; import { listGatewayMethods } from "./server-methods-list.js"; type GatewayPluginBootstrapLog = { @@ -90,16 +89,14 @@ export async function prepareGatewayPluginBootstrap(params: { // defaults injected while loading runtime config; runtime-only plugin config still merges in. const gatewayPluginConfig = params.minimalTestGateway ? params.cfgAtStart - : mergeActivationSectionsIntoRuntimeConfig({ + : resolveGatewayStartupPluginActivationConfig({ runtimeConfig: params.cfgAtStart, - activationConfig: applyPluginAutoEnable({ - config: activationSourceConfig, - env: process.env, - ...(params.pluginMetadataSnapshot?.manifestRegistry - ? { manifestRegistry: params.pluginMetadataSnapshot.manifestRegistry } - : {}), - discovery: params.pluginMetadataSnapshot?.discovery, - }).config, + activationSourceConfig, + env: process.env, + ...(params.pluginMetadataSnapshot?.manifestRegistry + ? { manifestRegistry: params.pluginMetadataSnapshot.manifestRegistry } + : {}), + discovery: params.pluginMetadataSnapshot?.discovery, }); const pluginsGloballyDisabled = gatewayPluginConfig.plugins?.enabled === false; const defaultAgentId = resolveDefaultAgentId(gatewayPluginConfig); diff --git a/src/status/status-plugin-health.installed.test.ts b/src/status/status-plugin-health.installed.test.ts new file mode 100644 index 00000000000..3efacd871f5 --- /dev/null +++ b/src/status/status-plugin-health.installed.test.ts @@ -0,0 +1,168 @@ +// Installed plugin health snapshot tests cover should-run drift wiring: the eager +// startup plan is read, deferred channel plugins are excluded, and the not-loaded +// remainder surfaces as drift in detailed status. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "../config/runtime-snapshot.js"; +import { resolveGatewayStartupPluginActivationConfig } from "../gateway/plugin-activation-runtime-config.js"; +import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js"; +import { loadGatewayStartupPluginPlan } from "../plugins/gateway-startup-plugin-ids.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; +import { formatDetailedPluginHealth } from "./status-plugin-health.js"; +import { collectInstalledPluginHealthSnapshot } from "./status-plugin-health.runtime.js"; + +vi.mock("../channels/plugins/read-only.js", () => ({ + resolveReadOnlyChannelPluginsForConfig: vi.fn(), +})); +// Keep the installed disk-scan report empty and deterministic so the snapshot's +// plugin records come only from the seeded runtime registry. +vi.mock("../plugins/status.js", async (importOriginal) => { + const actual = await importOriginal(); + const emptyReport = { plugins: [], diagnostics: [] } as unknown as ReturnType< + typeof actual.buildPluginSnapshotReport + >; + return { + ...actual, + buildPluginSnapshotReport: vi.fn(() => emptyReport), + buildPluginCompatibilityNotices: vi.fn(() => []), + } as typeof actual; +}); +// Override only the startup-plan resolver; preserve every other real export so any +// eager importer in the graph keeps working. +vi.mock("../plugins/gateway-startup-plugin-ids.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadGatewayStartupPluginPlan: vi.fn() } as typeof actual; +}); +// The startup-plan activation assembly is the gateway's own shared helper; mock it to +// identity (return the runtime config) so the status wiring stays deterministic. The helper's +// real auto-enable + merge behavior is covered by its own unit test and the gateway startup tests. +vi.mock("../gateway/plugin-activation-runtime-config.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + resolveGatewayStartupPluginActivationConfig: vi.fn( + (params: { runtimeConfig: unknown }) => + params.runtimeConfig as ReturnType< + typeof actual.resolveGatewayStartupPluginActivationConfig + >, + ), + } as typeof actual; +}); + +const resolveReadOnlyChannelPluginsForConfigMock = vi.mocked( + resolveReadOnlyChannelPluginsForConfig, +); +const loadGatewayStartupPluginPlanMock = vi.mocked(loadGatewayStartupPluginPlan); +const resolveGatewayStartupPluginActivationConfigMock = vi.mocked( + resolveGatewayStartupPluginActivationConfig, +); + +afterEach(() => { + resolveReadOnlyChannelPluginsForConfigMock.mockReset(); + loadGatewayStartupPluginPlanMock.mockReset(); + resolveGatewayStartupPluginActivationConfigMock.mockClear(); + clearRuntimeConfigSnapshot(); + resetPluginRuntimeStateForTest(); + resetPluginStateStoreForTests(); +}); + +describe("installed plugin health should-run drift", () => { + it("excludes deferred channel plugins and flags the not-loaded remainder as drift", async () => { + await withStateDirEnv("openclaw-status-should-run-drift-", async () => { + resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({ + loadFailures: [], + missingConfiguredChannelIds: [], + } as never); + loadGatewayStartupPluginPlanMock.mockReturnValue({ + channelPluginIds: [], + // deferred-chan finishes loading only after listen, so it must not count as drift. + configuredDeferredChannelPluginIds: ["deferred-chan"], + pluginIds: ["deferred-chan", "planned-missing", "runtime-ok"], + }); + + const registry = createEmptyPluginRegistry(); + registry.plugins.push({ id: "runtime-ok", status: "loaded", enabled: true } as never); + setActivePluginRegistry(registry, "runtime-ok", "default", "/tmp/ws"); + + const rawConfig = {} as never; + const snapshot = await collectInstalledPluginHealthSnapshot({ + config: rawConfig, + workspaceDir: "/tmp/ws", + }); + + // Plan resolved from the auto-enabled effective config with the raw config as the + // activation source — matching gateway boot, so auto-enabled plugins are not missed. + expect(loadGatewayStartupPluginPlanMock).toHaveBeenCalledWith( + expect.objectContaining({ config: rawConfig, activationSourceConfig: rawConfig }), + ); + // Deferred channel plugin dropped from the eager should-run set. + expect(snapshot.shouldRunPluginIds).toEqual(["planned-missing", "runtime-ok"]); + + const text = formatDetailedPluginHealth(snapshot); + expect(text).toContain("Loaded: 1 (runtime-ok)"); + expect(text).toContain("Configured to run but not loaded: 1 (planned-missing)"); + expect(text).not.toContain("deferred-chan"); + }); + }); + + it("builds the plan via the shared gateway helper using source + runtime config", async () => { + await withStateDirEnv("openclaw-status-should-run-source-cfg-", async () => { + resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({ + loadFailures: [], + missingConfiguredChannelIds: [], + } as never); + loadGatewayStartupPluginPlanMock.mockReturnValue({ + channelPluginIds: [], + configuredDeferredChannelPluginIds: [], + pluginIds: [], + }); + // /status passes the live runtime config; the activation source must be the original + // operator source config, and the effective config must be assembled from the runtime + // config (so runtime/defaulted fields survive) — via the shared gateway-boot helper. + const sourceConfig = { plugins: { entries: {} } } as never; + const runtimeConfig = { plugins: { entries: { extra: { enabled: true } } } } as never; + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + setActivePluginRegistry(createEmptyPluginRegistry(), "empty", "default", "/tmp/ws"); + + await collectInstalledPluginHealthSnapshot({ + config: runtimeConfig, + workspaceDir: "/tmp/ws", + }); + + // The shared gateway-boot helper assembles the effective config from the runtime config + // with the operator source config as the activation source (not the runtime snapshot). + expect(resolveGatewayStartupPluginActivationConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ runtimeConfig, activationSourceConfig: sourceConfig }), + ); + // The plan is resolved from that effective config (here the helper's identity output = + // runtimeConfig) with the operator source config as the activation source. + expect(loadGatewayStartupPluginPlanMock).toHaveBeenCalledWith( + expect.objectContaining({ config: runtimeConfig, activationSourceConfig: sourceConfig }), + ); + }); + }); + + it("omits the should-run set entirely when no config is provided", async () => { + await withStateDirEnv("openclaw-status-should-run-no-config-", async () => { + resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({ + loadFailures: [], + missingConfiguredChannelIds: [], + } as never); + setActivePluginRegistry(createEmptyPluginRegistry(), "empty", "default", "/tmp/ws"); + + const snapshot = await collectInstalledPluginHealthSnapshot({ workspaceDir: "/tmp/ws" }); + + expect(snapshot.shouldRunPluginIds).toBeUndefined(); + expect(loadGatewayStartupPluginPlanMock).not.toHaveBeenCalled(); + expect(formatDetailedPluginHealth(snapshot)).not.toContain( + "Configured to run but not loaded:", + ); + }); + }); +}); diff --git a/src/status/status-plugin-health.runtime.ts b/src/status/status-plugin-health.runtime.ts index 18c1343afbc..a0c73cadb24 100644 --- a/src/status/status-plugin-health.runtime.ts +++ b/src/status/status-plugin-health.runtime.ts @@ -208,7 +208,7 @@ export async function collectInstalledPluginHealthSnapshot(params: { report: runtimeRegistry, }).map(normalizeCompatibilityNotice) : []; - return mergeStatusPluginHealthSnapshots( + const merged = mergeStatusPluginHealthSnapshots( { plugins: report.plugins.map(normalizeSnapshotPlugin), diagnostics: installedDiagnostics, @@ -222,4 +222,51 @@ export async function collectInstalledPluginHealthSnapshot(params: { }, { ...runtime, compatibilityNotices: runtimeCompatibilityNotices }, ); + const shouldRunPluginIds = await resolveEagerShouldRunPluginIds(params); + return shouldRunPluginIds ? { ...merged, shouldRunPluginIds } : merged; +} + +// Eager should-run plugin ids from the gateway startup plan, with deferred channel +// plugins removed: their full load completes only after the gateway starts listening, +// so they would be benign mid-startup false positives in the runtime-loaded drift +// comparison. Detailed-status only and resolved lazily so the compact path never pulls +// the startup-plan module. Observer-only: any resolution failure (or absent config) +// degrades to no should-run set rather than breaking /status. +async function resolveEagerShouldRunPluginIds(params: { + config?: OpenClawConfig; + workspaceDir?: string; +}): Promise { + if (!params.config) { + return undefined; + } + try { + const { loadGatewayStartupPluginPlan } = + await import("../plugins/gateway-startup-plugin-ids.js"); + const { resolvePluginActivationSourceConfig } = + await import("../plugins/activation-source-config.js"); + const { resolveGatewayStartupPluginActivationConfig } = + await import("../gateway/plugin-activation-runtime-config.js"); + // Build the should-run plan with the exact assembly gateway boot uses, via the shared + // resolveGatewayStartupPluginActivationConfig helper. params.config is the live runtime + // snapshot; resolvePluginActivationSourceConfig maps it back to the operator source config + // the loader activates against, then the helper auto-enables that source and merges it into + // the runtime config (preserving runtime/defaulted fields). Reusing gateway boot's own helper + // keeps this set from drifting from prepareGatewayPluginBootstrap's plan. + const sourceConfig = resolvePluginActivationSourceConfig({ config: params.config }); + const effectiveConfig = resolveGatewayStartupPluginActivationConfig({ + runtimeConfig: params.config, + activationSourceConfig: sourceConfig, + env: process.env, + }); + const plan = loadGatewayStartupPluginPlan({ + config: effectiveConfig, + activationSourceConfig: sourceConfig, + env: process.env, + ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), + }); + const deferred = new Set(plan.configuredDeferredChannelPluginIds); + return plan.pluginIds.filter((id) => !deferred.has(id)); + } catch { + return undefined; + } } diff --git a/src/status/status-plugin-health.test.ts b/src/status/status-plugin-health.test.ts index b6a938f85ee..aeb2d4bcb95 100644 --- a/src/status/status-plugin-health.test.ts +++ b/src/status/status-plugin-health.test.ts @@ -323,4 +323,76 @@ describe("plugin health status formatting", () => { expect(text).toContain("Loaded: 2 (active-ok, pinned-only)"); expect(text).not.toContain("Installed (not active):"); }); + + it("flags should-run plugins missing from the runtime-loaded set as drift", () => { + const text = formatDetailedPluginHealth({ + plugins: [ + { id: "runtime-ok", status: "loaded", enabled: true }, + // Planned for eager startup but never loaded at runtime: drift. + { id: "planned-missing", status: "loaded", enabled: true }, + // Installed/discovered but not in the startup plan: neutral inventory. + { id: "not-planned-idle", status: "loaded", enabled: true }, + ], + diagnostics: [], + contextEngineQuarantines: [], + runtimeLoadedPluginIds: ["runtime-ok"], + shouldRunPluginIds: ["planned-missing", "runtime-ok"], + }); + + expect(text).toContain("Loaded: 1 (runtime-ok)"); + expect(text).toContain("Configured to run but not loaded: 1 (planned-missing)"); + // Not-planned stays neutral; the drift id never appears in both inventory lines. + expect(text).toContain("Installed (not active): 1 (not-planned-idle)"); + }); + + it("reports no drift when every should-run plugin is runtime-loaded", () => { + const text = formatDetailedPluginHealth({ + plugins: [ + { id: "a", status: "loaded", enabled: true }, + { id: "b", status: "loaded", enabled: true }, + ], + diagnostics: [], + contextEngineQuarantines: [], + runtimeLoadedPluginIds: ["a", "b"], + shouldRunPluginIds: ["a", "b"], + }); + + expect(text).toContain("Loaded: 2 (a, b)"); + expect(text).not.toContain("Configured to run but not loaded:"); + }); + + it("does not re-report should-run plugins already shown as error or disabled", () => { + const text = formatDetailedPluginHealth({ + plugins: [ + { id: "runtime-ok", 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"], + // Both broken and off are in the startup plan but already explained by their + // own records, so neither should surface as drift. + shouldRunPluginIds: ["broken", "off", "runtime-ok"], + }); + + expect(text).toContain("- broken [load]: boom"); + expect(text).toContain("Disabled: 1"); + expect(text).not.toContain("Configured to run but not loaded:"); + }); + + it("omits the drift line when the should-run set is absent (back-compat)", () => { + const text = formatDetailedPluginHealth({ + plugins: [ + { id: "runtime-ok", status: "loaded", enabled: true }, + { id: "installed-idle", status: "loaded", enabled: true }, + ], + diagnostics: [], + contextEngineQuarantines: [], + runtimeLoadedPluginIds: ["runtime-ok"], + }); + + expect(text).toContain("Installed (not active): 1 (installed-idle)"); + expect(text).not.toContain("Configured to run but not loaded:"); + }); }); diff --git a/src/status/status-plugin-health.ts b/src/status/status-plugin-health.ts index 2c9e21f981b..d7f35c4227d 100644 --- a/src/status/status-plugin-health.ts +++ b/src/status/status-plugin-health.ts @@ -65,6 +65,12 @@ export type StatusPluginHealthSnapshot = { // load). Absent on hand-built/compact snapshots, where detailed rendering falls // back to the merged status filter. runtimeLoadedPluginIds?: string[]; + // Eager should-run plugin ids from the gateway startup plan (deferred channel + // plugins already excluded). Paired with runtimeLoadedPluginIds, it lets detailed + // status flag desired-vs-observed drift: a plugin the gateway planned to start that + // is not in the runtime-loaded set. Absent on compact/hand-built snapshots, where + // no drift line is rendered (back-compat). + shouldRunPluginIds?: string[]; }; /** Keeps the first record per key; later duplicates are dropped. */ @@ -254,10 +260,30 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot) const runtimeLoadedIds = snapshot.runtimeLoadedPluginIds; const runtimeLoaded = runtimeLoadedIds ? new Set(runtimeLoadedIds) : undefined; const loaded = (runtimeLoadedIds ?? statusLoaded.map((plugin) => plugin.id)).toSorted(byLocale); + // Desired-vs-observed drift: ids the gateway's eager startup plan says should run + // but that are absent from the runtime-loaded set and not already explained by an + // error/disabled record (those surface in their own sections). Computed only when + // both the should-run set and runtime provenance are present, so compact/hand-built + // snapshots render exactly as before. + const explainedPluginIds = new Set( + snapshot.plugins + .filter((plugin) => plugin.status === "error" || plugin.status === "disabled") + .map((plugin) => plugin.id), + ); + const shouldRunNotLoaded = + snapshot.shouldRunPluginIds && runtimeLoaded + ? snapshot.shouldRunPluginIds + .filter((id) => !runtimeLoaded.has(id) && !explainedPluginIds.has(id)) + .toSorted(byLocale) + : []; + const shouldRunNotLoadedSet = new Set(shouldRunNotLoaded); const installedNotActive = runtimeLoaded ? statusLoaded .filter((plugin) => !runtimeLoaded.has(plugin.id)) .map((plugin) => plugin.id) + // Drift ids are reported on their own line below; keep them out of the + // neutral "Installed (not active)" inventory so each id appears once. + .filter((id) => !shouldRunNotLoadedSet.has(id)) .toSorted(byLocale) : []; const disabled = snapshot.plugins.filter((plugin) => plugin.status === "disabled").length; @@ -296,6 +322,16 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot) ); } + if (shouldRunNotLoaded.length > 0) { + // Planned for eager startup but missing from the live runtime-loaded set (e.g., + // config changed since the gateway started, or a planned plugin did not come up). + // Observer-only signal, distinct from neutral inventory; not an error chip and + // not counted in the compact line. + lines.push( + `Configured to run but not loaded: ${shouldRunNotLoaded.length} (${formatPluginList(shouldRunNotLoaded, 8)})`, + ); + } + if (errors.length > 0) { lines.push( `Errors: ${errors.length}`,