mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(status): surface should-run plugin drift (#97878)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
beab0ecb02
commit
888f399499
@@ -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<typeof applyPluginAutoEnable>);
|
||||
|
||||
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<typeof applyPluginAutoEnable>);
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof import("../plugins/status.js")>();
|
||||
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<typeof import("../plugins/gateway-startup-plugin-ids.js")>();
|
||||
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<typeof import("../gateway/plugin-activation-runtime-config.js")>();
|
||||
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:",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string[] | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user