Plugins: extract loader candidate planning
This commit is contained in:
parent
032b5dee20
commit
bce8b67777
@ -33,6 +33,7 @@ This is an implementation checklist, not a future-design spec.
|
||||
| Manifest/package metadata loading | `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/schema.ts` and `src/extension-host/manifest-registry.ts` | `partial` | Package metadata parsing is routed through host schema helpers; legacy loader flow still supplies the source manifests. |
|
||||
| Loader SDK alias compatibility | `src/plugins/loader.ts` | `src/extension-host/loader-compat.ts` | `partial` | Plugin-SDK alias candidate ordering, alias-file resolution, and scoped alias-map construction now live in host-owned loader compatibility helpers. |
|
||||
| Loader provenance and duplicate-order policy | `src/plugins/loader.ts` | `src/extension-host/loader-policy.ts` | `partial` | Plugin-record creation, duplicate precedence, provenance indexing, and allowlist/untracked warnings now live in host-owned loader-policy helpers. |
|
||||
| Loader initial candidate planning and record creation | `src/plugins/loader.ts` | `src/extension-host/loader-records.ts` | `partial` | Duplicate detection, initial record creation, manifest metadata attachment, and first-pass enable-state planning now delegate through host-owned loader-records helpers. |
|
||||
| Loader module-export, config-validation, and memory-slot decisions | `src/plugins/loader.ts` | `src/extension-host/loader-runtime.ts` | `partial` | Module export resolution, export-metadata application, config validation, and early or final memory-slot decisions now delegate through host-owned loader-runtime helpers. |
|
||||
| Loader record-state transitions | `src/plugins/loader.ts` | `src/extension-host/loader-state.ts` | `partial` | Disabled, error, and appended plugin-record state transitions now delegate through host-owned loader-state helpers; a real lifecycle state machine still does not exist. |
|
||||
| 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. |
|
||||
@ -80,14 +81,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, policy, runtime decisions, and record-state transitions
|
||||
- loader compatibility, initial candidate planning, policy, runtime decisions, and record-state transitions
|
||||
|
||||
## 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. Move the remaining loader orchestration into `src/extension-host/*`, especially per-plugin load flow, enablement, and lifecycle-state transitions.
|
||||
2. Move the remaining loader orchestration into `src/extension-host/*`, especially per-plugin import and registration flow, enablement completion, and lifecycle-state transitions.
|
||||
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.
|
||||
|
||||
161
src/extension-host/loader-records.test.ts
Normal file
161
src/extension-host/loader-records.test.ts
Normal file
@ -0,0 +1,161 @@
|
||||
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 { prepareExtensionHostPluginCandidate } from "./loader-records.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 records", () => {
|
||||
it("prepares duplicate candidates as disabled compatibility records", () => {
|
||||
const seenIds = new Map<string, "workspace" | "global" | "bundled" | "config">([
|
||||
["demo", "bundled"],
|
||||
]);
|
||||
|
||||
const prepared = prepareExtensionHostPluginCandidate({
|
||||
candidate: createCandidate(),
|
||||
manifestRecord: createManifestRecord(),
|
||||
normalizedConfig: normalizePluginsConfig({}),
|
||||
rootConfig: {},
|
||||
seenIds,
|
||||
});
|
||||
|
||||
expect(prepared).toMatchObject({
|
||||
kind: "duplicate",
|
||||
pluginId: "demo",
|
||||
record: {
|
||||
enabled: false,
|
||||
status: "disabled",
|
||||
error: "overridden by bundled plugin",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prepares candidate records with manifest metadata and config entry", () => {
|
||||
const rootConfig: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: true,
|
||||
config: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const prepared = prepareExtensionHostPluginCandidate({
|
||||
candidate: createCandidate({ origin: "bundled" }),
|
||||
manifestRecord: createManifestRecord({ origin: "bundled" }),
|
||||
normalizedConfig: normalizePluginsConfig(rootConfig.plugins),
|
||||
rootConfig,
|
||||
seenIds: new Map(),
|
||||
});
|
||||
|
||||
expect(prepared).toMatchObject({
|
||||
kind: "candidate",
|
||||
pluginId: "demo",
|
||||
entry: {
|
||||
enabled: true,
|
||||
config: { enabled: true },
|
||||
},
|
||||
enableState: {
|
||||
enabled: true,
|
||||
},
|
||||
record: {
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
kind: "tool",
|
||||
configJsonSchema: {
|
||||
type: "object",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves disabled-by-config decisions in the prepared record", () => {
|
||||
const rootConfig: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const prepared = prepareExtensionHostPluginCandidate({
|
||||
candidate: createCandidate({ origin: "bundled" }),
|
||||
manifestRecord: createManifestRecord({ origin: "bundled" }),
|
||||
normalizedConfig: normalizePluginsConfig(rootConfig.plugins),
|
||||
rootConfig,
|
||||
seenIds: new Map(),
|
||||
});
|
||||
|
||||
expect(prepared).toMatchObject({
|
||||
kind: "candidate",
|
||||
enableState: {
|
||||
enabled: false,
|
||||
reason: "disabled in config",
|
||||
},
|
||||
record: {
|
||||
enabled: false,
|
||||
status: "disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
93
src/extension-host/loader-records.ts
Normal file
93
src/extension-host/loader-records.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveEffectiveEnableState,
|
||||
type NormalizedPluginsConfig,
|
||||
} from "../plugins/config-state.js";
|
||||
import type { PluginCandidate } from "../plugins/discovery.js";
|
||||
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
|
||||
import type { PluginRecord } from "../plugins/registry.js";
|
||||
import { createExtensionHostPluginRecord } from "./loader-policy.js";
|
||||
import { setExtensionHostPluginRecordDisabled } from "./loader-state.js";
|
||||
|
||||
type CandidateEntry = NormalizedPluginsConfig["entries"][string];
|
||||
|
||||
export type ExtensionHostPreparedPluginCandidate =
|
||||
| {
|
||||
kind: "duplicate";
|
||||
pluginId: string;
|
||||
record: PluginRecord;
|
||||
}
|
||||
| {
|
||||
kind: "candidate";
|
||||
pluginId: string;
|
||||
record: PluginRecord;
|
||||
entry: CandidateEntry | undefined;
|
||||
enableState: { enabled: boolean; reason?: string };
|
||||
};
|
||||
|
||||
export function prepareExtensionHostPluginCandidate(params: {
|
||||
candidate: PluginCandidate;
|
||||
manifestRecord: PluginManifestRecord;
|
||||
normalizedConfig: NormalizedPluginsConfig;
|
||||
rootConfig: OpenClawConfig;
|
||||
seenIds: Map<string, PluginRecord["origin"]>;
|
||||
}): ExtensionHostPreparedPluginCandidate {
|
||||
const pluginId = params.manifestRecord.id;
|
||||
const existingOrigin = params.seenIds.get(pluginId);
|
||||
if (existingOrigin) {
|
||||
const record = createBasePluginRecord({
|
||||
candidate: params.candidate,
|
||||
manifestRecord: params.manifestRecord,
|
||||
enabled: false,
|
||||
});
|
||||
setExtensionHostPluginRecordDisabled(record, `overridden by ${existingOrigin} plugin`);
|
||||
return {
|
||||
kind: "duplicate",
|
||||
pluginId,
|
||||
record,
|
||||
};
|
||||
}
|
||||
|
||||
const enableState = resolveEffectiveEnableState({
|
||||
id: pluginId,
|
||||
origin: params.candidate.origin,
|
||||
config: params.normalizedConfig,
|
||||
rootConfig: params.rootConfig,
|
||||
});
|
||||
const entry = params.normalizedConfig.entries[pluginId];
|
||||
const record = createBasePluginRecord({
|
||||
candidate: params.candidate,
|
||||
manifestRecord: params.manifestRecord,
|
||||
enabled: enableState.enabled,
|
||||
});
|
||||
return {
|
||||
kind: "candidate",
|
||||
pluginId,
|
||||
record,
|
||||
entry,
|
||||
enableState,
|
||||
};
|
||||
}
|
||||
|
||||
function createBasePluginRecord(params: {
|
||||
candidate: PluginCandidate;
|
||||
manifestRecord: PluginManifestRecord;
|
||||
enabled: boolean;
|
||||
}): PluginRecord {
|
||||
const pluginId = params.manifestRecord.id;
|
||||
const record = createExtensionHostPluginRecord({
|
||||
id: pluginId,
|
||||
name: params.manifestRecord.name ?? pluginId,
|
||||
description: params.manifestRecord.description,
|
||||
version: params.manifestRecord.version,
|
||||
source: params.candidate.source,
|
||||
origin: params.candidate.origin,
|
||||
workspaceDir: params.candidate.workspaceDir,
|
||||
enabled: params.enabled,
|
||||
configSchema: Boolean(params.manifestRecord.configSchema),
|
||||
});
|
||||
record.kind = params.manifestRecord.kind;
|
||||
record.configUiHints = params.manifestRecord.configUiHints;
|
||||
record.configJsonSchema = params.manifestRecord.configSchema;
|
||||
return record;
|
||||
}
|
||||
@ -15,12 +15,12 @@ import {
|
||||
import {
|
||||
buildExtensionHostProvenanceIndex,
|
||||
compareExtensionHostDuplicateCandidateOrder,
|
||||
createExtensionHostPluginRecord,
|
||||
pushExtensionHostDiagnostics,
|
||||
recordExtensionHostPluginError,
|
||||
warnAboutUntrackedLoadedExtensions,
|
||||
warnWhenExtensionAllowlistIsOpen,
|
||||
} from "../extension-host/loader-policy.js";
|
||||
import { prepareExtensionHostPluginCandidate } from "../extension-host/loader-records.js";
|
||||
import {
|
||||
applyExtensionHostDefinitionToRecord,
|
||||
resolveExtensionHostEarlyMemoryDecision,
|
||||
@ -41,7 +41,6 @@ import { clearPluginCommands } from "./commands.js";
|
||||
import {
|
||||
applyTestPluginDefaults,
|
||||
normalizePluginsConfig,
|
||||
resolveEffectiveEnableState,
|
||||
type NormalizedPluginsConfig,
|
||||
} from "./config-state.js";
|
||||
import { discoverOpenClawPlugins } from "./discovery.js";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user