Plugins: add loader activation policy
This commit is contained in:
parent
90ef0afe14
commit
2777895047
@ -41,6 +41,7 @@ This is an implementation checklist, not a future-design spec.
|
||||
| Loader per-candidate orchestration | `src/plugins/loader.ts` | `src/extension-host/loader-flow.ts` | `partial` | The per-candidate load flow now runs through a host-owned orchestrator that composes planning, import, runtime validation, register execution, and record-state helpers. |
|
||||
| Loader top-level load orchestration | `src/plugins/loader.ts` | `src/extension-host/loader-orchestrator.ts` | `partial` | Cache hits, runtime creation, discovery, manifest loading, candidate ordering, candidate processing, and finalization now route through a host-owned loader orchestrator while `src/plugins/loader.ts` remains the compatibility facade. |
|
||||
| Loader mutable activation state session | local variables in `src/plugins/loader.ts` and `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-session.ts` | `partial` | Seen-id tracking, memory-slot selection state, and finalization inputs now live in a host-owned loader session instead of being spread across top-level loader variables. |
|
||||
| Loader activation policy outcomes | open-coded in `src/plugins/loader.ts` and `src/extension-host/loader-flow.ts` | `src/extension-host/loader-activation-policy.ts` | `partial` | Duplicate precedence, config enablement, and early memory-slot gating now resolve through explicit host-owned activation-policy outcomes instead of remaining as inline loader decisions. |
|
||||
| Loader record-state transitions | `src/plugins/loader.ts` | `src/extension-host/loader-state.ts` | `partial` | The loader now enforces an explicit lifecycle transition model (`prepared -> imported -> validated -> registered -> ready`, plus terminal `disabled` and `error`) while still mapping back to compatibility `PluginRecord.status` values. |
|
||||
| Loader final cache, warning, and activation finalization | `src/plugins/loader.ts` | `src/extension-host/loader-finalize.ts` | `partial` | Cache writes, untracked-extension warnings, final memory-slot warnings, readiness promotion, and registry activation now delegate through a host-owned loader-finalize helper; broader host lifecycle and policy semantics are still pending. |
|
||||
| Channel lookup | `src/channels/plugins/index.ts`, `src/channels/plugins/registry-loader.ts`, `src/channels/registry.ts` | extension-host-backed registries plus kernel channel contracts | `partial` | Readers now consume the host-owned active registry, but writes still originate from plugin registration. |
|
||||
@ -88,14 +89,14 @@ That pattern has been used for:
|
||||
- active registry ownership
|
||||
- normalized extension schema and resolved-extension records
|
||||
- static consumers such as skills, validation, auto-enable, and config baseline generation
|
||||
- loader compatibility, cache control, initial candidate planning, entry-path import, policy, runtime decisions, post-import register flow, per-candidate orchestration, top-level load orchestration, session-owned activation state, explicit loader lifecycle transitions, and final cache plus activation finalization
|
||||
- loader compatibility, cache control, initial candidate planning, entry-path import, explicit activation-policy outcomes, runtime decisions, post-import register flow, per-candidate orchestration, top-level load orchestration, session-owned activation state, explicit loader lifecycle transitions, and final cache plus activation finalization
|
||||
|
||||
## Immediate Next Targets
|
||||
|
||||
These are the next lowest-risk cutover steps:
|
||||
|
||||
1. Replace remaining static-only manifest-registry injections with resolved-extension registry inputs where practical.
|
||||
2. Extend the new loader lifecycle state machine and session-owned activation state into broader activation-state and policy ownership in `src/extension-host/*`.
|
||||
2. Extend the new loader lifecycle state machine, session-owned activation state, and activation-policy outcomes into broader activation-state and policy ownership in `src/extension-host/*`.
|
||||
3. Introduce explicit host-owned registration surfaces for runtime writes, starting with the least-coupled registries.
|
||||
4. Move minimal SDK compatibility and loader normalization into `src/extension-host/*` without breaking current `openclaw/plugin-sdk/*` loading.
|
||||
5. Start the first pilot on `extensions/thread-ownership` only after the host-side registry and lifecycle seams are explicit.
|
||||
|
||||
138
src/extension-host/loader-activation-policy.test.ts
Normal file
138
src/extension-host/loader-activation-policy.test.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { normalizePluginsConfig } from "../plugins/config-state.js";
|
||||
import type { PluginCandidate } from "../plugins/discovery.js";
|
||||
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
|
||||
import { resolveExtensionHostActivationPolicy } from "./loader-activation-policy.js";
|
||||
|
||||
function createCandidate(overrides: Partial<PluginCandidate> = {}): PluginCandidate {
|
||||
return {
|
||||
source: "/plugins/demo/index.ts",
|
||||
rootDir: "/plugins/demo",
|
||||
packageDir: "/plugins/demo",
|
||||
origin: "workspace",
|
||||
workspaceDir: "/workspace",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createManifestRecord(overrides: Partial<PluginManifestRecord> = {}): PluginManifestRecord {
|
||||
return {
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
description: "Demo plugin",
|
||||
version: "1.0.0",
|
||||
kind: "tool",
|
||||
channels: [],
|
||||
providers: [],
|
||||
skills: [],
|
||||
origin: "workspace",
|
||||
workspaceDir: "/workspace",
|
||||
rootDir: "/plugins/demo",
|
||||
source: "/plugins/demo/index.ts",
|
||||
manifestPath: "/plugins/demo/openclaw.plugin.json",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
configUiHints: {
|
||||
enabled: { sensitive: false },
|
||||
},
|
||||
resolvedExtension: {
|
||||
id: "demo",
|
||||
source: "/plugins/demo/index.ts",
|
||||
origin: "workspace",
|
||||
rootDir: "/plugins/demo",
|
||||
workspaceDir: "/workspace",
|
||||
static: {
|
||||
package: {},
|
||||
config: {},
|
||||
setup: {},
|
||||
},
|
||||
runtime: {
|
||||
kind: "tool",
|
||||
contributions: [],
|
||||
},
|
||||
policy: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("extension host loader activation policy", () => {
|
||||
it("returns duplicate policy outcomes", () => {
|
||||
const outcome = resolveExtensionHostActivationPolicy({
|
||||
candidate: createCandidate(),
|
||||
manifestRecord: createManifestRecord(),
|
||||
normalizedConfig: normalizePluginsConfig({}),
|
||||
rootConfig: {},
|
||||
seenIds: new Map([["demo", "bundled" as const]]),
|
||||
selectedMemoryPluginId: null,
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
kind: "duplicate",
|
||||
pluginId: "demo",
|
||||
record: {
|
||||
status: "disabled",
|
||||
error: "overridden by bundled plugin",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns disabled policy outcomes for config-disabled plugins", () => {
|
||||
const rootConfig: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const outcome = resolveExtensionHostActivationPolicy({
|
||||
candidate: createCandidate(),
|
||||
manifestRecord: createManifestRecord(),
|
||||
normalizedConfig: normalizePluginsConfig(rootConfig.plugins),
|
||||
rootConfig,
|
||||
seenIds: new Map(),
|
||||
selectedMemoryPluginId: null,
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
kind: "disabled",
|
||||
pluginId: "demo",
|
||||
reason: "disabled in config",
|
||||
record: {
|
||||
status: "disabled",
|
||||
lifecycleState: "disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns candidate outcomes when policy allows activation", () => {
|
||||
const outcome = resolveExtensionHostActivationPolicy({
|
||||
candidate: createCandidate({ origin: "bundled" }),
|
||||
manifestRecord: createManifestRecord({ origin: "bundled", kind: "memory" }),
|
||||
normalizedConfig: normalizePluginsConfig({
|
||||
slots: {
|
||||
memory: "demo",
|
||||
},
|
||||
}),
|
||||
rootConfig: {},
|
||||
seenIds: new Map(),
|
||||
selectedMemoryPluginId: null,
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
kind: "candidate",
|
||||
pluginId: "demo",
|
||||
record: {
|
||||
lifecycleState: "prepared",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
114
src/extension-host/loader-activation-policy.ts
Normal file
114
src/extension-host/loader-activation-policy.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginCandidate } from "../plugins/discovery.js";
|
||||
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
|
||||
import type { PluginRecord } from "../plugins/registry.js";
|
||||
import { prepareExtensionHostPluginCandidate } from "./loader-records.js";
|
||||
import { resolveExtensionHostEarlyMemoryDecision } from "./loader-runtime.js";
|
||||
import { setExtensionHostPluginRecordDisabled } from "./loader-state.js";
|
||||
|
||||
export type ExtensionHostActivationPolicyOutcome =
|
||||
| {
|
||||
kind: "duplicate";
|
||||
pluginId: string;
|
||||
record: PluginRecord;
|
||||
}
|
||||
| {
|
||||
kind: "disabled";
|
||||
pluginId: string;
|
||||
record: PluginRecord;
|
||||
entry:
|
||||
| {
|
||||
enabled?: boolean;
|
||||
hooks?: {
|
||||
allowPromptInjection?: boolean;
|
||||
};
|
||||
config?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
kind: "candidate";
|
||||
pluginId: string;
|
||||
record: PluginRecord;
|
||||
entry:
|
||||
| {
|
||||
enabled?: boolean;
|
||||
hooks?: {
|
||||
allowPromptInjection?: boolean;
|
||||
};
|
||||
config?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export function resolveExtensionHostActivationPolicy(params: {
|
||||
candidate: PluginCandidate;
|
||||
manifestRecord: PluginManifestRecord;
|
||||
normalizedConfig: {
|
||||
entries: Record<
|
||||
string,
|
||||
{
|
||||
enabled?: boolean;
|
||||
hooks?: {
|
||||
allowPromptInjection?: boolean;
|
||||
};
|
||||
config?: unknown;
|
||||
}
|
||||
>;
|
||||
slots: {
|
||||
memory?: string | null;
|
||||
};
|
||||
};
|
||||
rootConfig: OpenClawConfig;
|
||||
seenIds: Map<string, PluginRecord["origin"]>;
|
||||
selectedMemoryPluginId: string | null;
|
||||
}): ExtensionHostActivationPolicyOutcome {
|
||||
const preparedCandidate = prepareExtensionHostPluginCandidate({
|
||||
candidate: params.candidate,
|
||||
manifestRecord: params.manifestRecord,
|
||||
normalizedConfig: params.normalizedConfig,
|
||||
rootConfig: params.rootConfig,
|
||||
seenIds: params.seenIds,
|
||||
});
|
||||
if (preparedCandidate.kind === "duplicate") {
|
||||
return preparedCandidate;
|
||||
}
|
||||
|
||||
const { pluginId, record, entry, enableState } = preparedCandidate;
|
||||
if (!enableState.enabled) {
|
||||
setExtensionHostPluginRecordDisabled(record, enableState.reason);
|
||||
return {
|
||||
kind: "disabled",
|
||||
pluginId,
|
||||
record,
|
||||
entry,
|
||||
reason: enableState.reason,
|
||||
};
|
||||
}
|
||||
|
||||
const earlyMemoryDecision = resolveExtensionHostEarlyMemoryDecision({
|
||||
origin: params.candidate.origin,
|
||||
manifestKind: params.manifestRecord.kind,
|
||||
recordId: record.id,
|
||||
memorySlot: params.normalizedConfig.slots.memory,
|
||||
selectedMemoryPluginId: params.selectedMemoryPluginId,
|
||||
});
|
||||
if (!earlyMemoryDecision.enabled) {
|
||||
setExtensionHostPluginRecordDisabled(record, earlyMemoryDecision.reason);
|
||||
return {
|
||||
kind: "disabled",
|
||||
pluginId,
|
||||
record,
|
||||
entry,
|
||||
reason: earlyMemoryDecision.reason,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "candidate",
|
||||
pluginId,
|
||||
record,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
@ -3,21 +3,17 @@ import type { PluginCandidate } from "../plugins/discovery.js";
|
||||
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
|
||||
import type { PluginRecord, PluginRegistry } from "../plugins/registry.js";
|
||||
import type { OpenClawPluginApi, OpenClawPluginModule, PluginLogger } from "../plugins/types.js";
|
||||
import { resolveExtensionHostActivationPolicy } from "./loader-activation-policy.js";
|
||||
import { importExtensionHostPluginModule } from "./loader-import.js";
|
||||
import { recordExtensionHostPluginError } from "./loader-policy.js";
|
||||
import { prepareExtensionHostPluginCandidate } from "./loader-records.js";
|
||||
import {
|
||||
planExtensionHostLoadedPlugin,
|
||||
runExtensionHostPluginRegister,
|
||||
} from "./loader-register.js";
|
||||
import {
|
||||
resolveExtensionHostEarlyMemoryDecision,
|
||||
resolveExtensionHostModuleExport,
|
||||
} from "./loader-runtime.js";
|
||||
import { resolveExtensionHostModuleExport } from "./loader-runtime.js";
|
||||
import {
|
||||
appendExtensionHostPluginRecord,
|
||||
setExtensionHostPluginRecordLifecycleState,
|
||||
setExtensionHostPluginRecordDisabled,
|
||||
setExtensionHostPluginRecordError,
|
||||
} from "./loader-state.js";
|
||||
|
||||
@ -56,18 +52,18 @@ export function processExtensionHostPluginCandidate(params: {
|
||||
loadModule: (safeSource: string) => OpenClawPluginModule;
|
||||
}): { selectedMemoryPluginId: string | null; memorySlotMatched: boolean } {
|
||||
const { candidate, manifestRecord } = params;
|
||||
const pluginId = manifestRecord.id;
|
||||
const preparedCandidate = prepareExtensionHostPluginCandidate({
|
||||
const activationPolicy = resolveExtensionHostActivationPolicy({
|
||||
candidate,
|
||||
manifestRecord,
|
||||
normalizedConfig: params.normalizedConfig,
|
||||
rootConfig: params.rootConfig,
|
||||
seenIds: params.seenIds,
|
||||
selectedMemoryPluginId: params.selectedMemoryPluginId,
|
||||
});
|
||||
if (preparedCandidate.kind === "duplicate") {
|
||||
if (activationPolicy.kind === "duplicate") {
|
||||
appendExtensionHostPluginRecord({
|
||||
registry: params.registry,
|
||||
record: preparedCandidate.record,
|
||||
record: activationPolicy.record,
|
||||
});
|
||||
return {
|
||||
selectedMemoryPluginId: params.selectedMemoryPluginId,
|
||||
@ -75,7 +71,7 @@ export function processExtensionHostPluginCandidate(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const { record, entry, enableState } = preparedCandidate;
|
||||
const { pluginId, record, entry } = activationPolicy;
|
||||
const pushPluginLoadError = (message: string) => {
|
||||
setExtensionHostPluginRecordError(record, message);
|
||||
appendExtensionHostPluginRecord({
|
||||
@ -93,30 +89,7 @@ export function processExtensionHostPluginCandidate(params: {
|
||||
});
|
||||
};
|
||||
|
||||
if (!enableState.enabled) {
|
||||
setExtensionHostPluginRecordDisabled(record, enableState.reason);
|
||||
appendExtensionHostPluginRecord({
|
||||
registry: params.registry,
|
||||
record,
|
||||
seenIds: params.seenIds,
|
||||
pluginId,
|
||||
origin: candidate.origin,
|
||||
});
|
||||
return {
|
||||
selectedMemoryPluginId: params.selectedMemoryPluginId,
|
||||
memorySlotMatched: false,
|
||||
};
|
||||
}
|
||||
|
||||
const earlyMemoryDecision = resolveExtensionHostEarlyMemoryDecision({
|
||||
origin: candidate.origin,
|
||||
manifestKind: manifestRecord.kind,
|
||||
recordId: record.id,
|
||||
memorySlot: params.normalizedConfig.slots.memory,
|
||||
selectedMemoryPluginId: params.selectedMemoryPluginId,
|
||||
});
|
||||
if (!earlyMemoryDecision.enabled) {
|
||||
setExtensionHostPluginRecordDisabled(record, earlyMemoryDecision.reason);
|
||||
if (activationPolicy.kind === "disabled") {
|
||||
appendExtensionHostPluginRecord({
|
||||
registry: params.registry,
|
||||
record,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user