mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
da4a656cdb
* perf(plugins): declare doctor contract surfaces * perf(doctor): slim migration import closures * perf(plugins): narrow doctor declaration record surface and wire owner-test lane Registry records carry only the doctorContract declaration instead of the whole parsed manifest, and check:changed now selects the src/plugins-owned declaration honesty and closure-guard tests for extension module/manifest changes so cross-lane drift cannot pass PR classification. * fix(doctor): keep control-plane dist imports require-safe Keep doctor and channel control-plane chunks off exec-class dependencies, and enforce native require(esm) loading during postbuild. * chore(plugin-sdk): regenerate API baseline * chore(plugin-sdk): sync export ordering * fix(plugins): satisfy doctor contract CI boundaries * perf(doctor): make qqbot doctor closure dependency-light qqbot was the last plugin above 5s in doctor state-migration enumeration (~8s under tsx/jiti). The cost was not the state-key builder (already a leaf): its doctor closure value-imported the runtime-doctor SDK barrel, whose plugin-state-store/state-db re-exports pull kysely (~330 modules), plus security-runtime for one fileExists (~200 modules), all resolved per-module by jiti during enumeration. Split the migration-define helpers and light re-exports into a new private-local plugin-sdk/runtime-doctor-migrations subpath; runtime-doctor re-exports it so its public surface is byte-identical (API baseline hash unchanged). qqbot's doctor-contract and state-migrations now import only the light subpath, swapping fileExists for the equivalent async legacyStateFileExists already in the closure. qqbot enumeration: ~8.0s/531 modules -> ~0.25s/18 modules. * chore(plugin-sdk): drop private-local subpath from API baseline runtime-doctor-migrations is private-local-only; the baseline tracks public modules, and the earlier line was generated before the classification. * fix(plugins): register runtime-doctor-migrations boundary paths The private-local subpath list feeds the extension package boundary map; the shared paths config and xai's derived overrides must carry the same entry or the boundary contract test fails.
178 lines
6.4 KiB
TypeScript
178 lines
6.4 KiB
TypeScript
// Qqbot plugin module implements doctor contract behavior.
|
|
import type {
|
|
ChannelDoctorConfigMutation,
|
|
ChannelDoctorLegacyConfigRule,
|
|
} from "openclaw/plugin-sdk/channel-contract";
|
|
import type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/channel-policy";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import {
|
|
asObjectRecord,
|
|
defineKeyMoveMigration,
|
|
hasLegacyAccountStreamingAliases,
|
|
normalizeChannelConfigEntries,
|
|
} from "openclaw/plugin-sdk/runtime-doctor-migrations";
|
|
|
|
const RESTRICTED_GROUP_TOOLS: GroupToolPolicyConfig = {
|
|
deny: ["exec", "read", "write"],
|
|
};
|
|
|
|
const streamingTransportMigration = defineKeyMoveMigration({
|
|
from: ["streaming", "c2cStreamApi"],
|
|
to: ["streaming", "nativeTransport"],
|
|
match: (value) => value !== undefined,
|
|
sourceOwn: false,
|
|
});
|
|
|
|
// QQBot's legacy scalar `streaming` is not a plain mode alias: `true` enabled
|
|
// block streaming AND the official C2C stream API (shouldUseOfficialC2cStream
|
|
// treated `true` like `c2cStreamApi: true`), while `false` only disabled block
|
|
// streaming. It migrates to the nested `{mode, nativeTransport}` shape here
|
|
// instead of the shared alias DSL because qqbot has no flat delivery aliases
|
|
// and its strict streaming schema rejects the DSL's chunkMode/block slots.
|
|
// No account seeding: named accounts never inherit root config (bridge/config
|
|
// resolves them standalone), and the boolean carries its full semantics.
|
|
function hasLegacyStreamingValue(value: unknown): boolean {
|
|
const entry = asObjectRecord(value);
|
|
if (!entry) {
|
|
return false;
|
|
}
|
|
return typeof entry.streaming === "boolean" || streamingTransportMigration.hasLegacy(entry);
|
|
}
|
|
|
|
function migrateStreamingValue(params: {
|
|
entry: Record<string, unknown>;
|
|
pathPrefix: string;
|
|
changes: string[];
|
|
}): { entry: Record<string, unknown>; changed: boolean } {
|
|
const streaming = params.entry.streaming;
|
|
const path = `${params.pathPrefix}.streaming`;
|
|
if (typeof streaming === "boolean") {
|
|
const next: Record<string, unknown> = streaming
|
|
? { mode: "partial", nativeTransport: true }
|
|
: { mode: "off" };
|
|
params.changes.push(`Moved ${path} (boolean) → ${path}.mode (${next.mode as string}).`);
|
|
if (streaming) {
|
|
// `streaming: true` also enabled the official C2C stream API.
|
|
params.changes.push(`Moved ${path} (boolean) → ${path}.nativeTransport.`);
|
|
}
|
|
return { entry: { ...params.entry, streaming: next }, changed: true };
|
|
}
|
|
return streamingTransportMigration.normalize(params);
|
|
}
|
|
|
|
function migrateToolPolicy(value: unknown): GroupToolPolicyConfig | undefined {
|
|
if (value === "none") {
|
|
return { deny: ["*"] };
|
|
}
|
|
if (value === "full") {
|
|
return { allow: [] };
|
|
}
|
|
if (value === "restricted") {
|
|
return { ...RESTRICTED_GROUP_TOOLS };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function describeToolPolicy(value: unknown): string {
|
|
return typeof value === "string" ? value : String(value);
|
|
}
|
|
|
|
const groupToolPolicyMigration = defineKeyMoveMigration({
|
|
scope: ["*"],
|
|
from: ["toolPolicy"],
|
|
to: ["tools"],
|
|
match: (value) => value !== undefined,
|
|
sourceOwn: false,
|
|
map: (value) => {
|
|
const policy = migrateToolPolicy(value);
|
|
return policy ? { value: policy } : null;
|
|
},
|
|
movedMessage: ({ sourcePath, targetPath, sourceValue }) =>
|
|
`Moved ${sourcePath}=${describeToolPolicy(sourceValue)} to ${targetPath}.`,
|
|
existingMessage: ({ sourcePath, targetPath }) =>
|
|
`Removed ${sourcePath} (${targetPath} already exists).`,
|
|
invalidMessage: ({ sourcePath, sourceValue }) =>
|
|
`Removed unsupported ${sourcePath}=${describeToolPolicy(sourceValue)}.`,
|
|
});
|
|
|
|
const voiceDirectUploadFormatsMigration = defineKeyMoveMigration({
|
|
from: ["voiceDirectUploadFormats"],
|
|
to: ["audioFormatPolicy", "uploadDirectFormats"],
|
|
match: (value) => value !== undefined,
|
|
sourceOwn: false,
|
|
});
|
|
|
|
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
|
{
|
|
path: ["channels", "qqbot"],
|
|
message:
|
|
'channels.qqbot streaming aliases and voiceDirectUploadFormats are legacy; use streaming.{mode,nativeTransport} and audioFormatPolicy.uploadDirectFormats. Run "openclaw doctor --fix".',
|
|
match: (value) =>
|
|
hasLegacyStreamingValue(value) || voiceDirectUploadFormatsMigration.hasLegacy(value),
|
|
},
|
|
{
|
|
path: ["channels", "qqbot", "accounts"],
|
|
message:
|
|
'channels.qqbot account streaming aliases and voiceDirectUploadFormats are legacy; use streaming.{mode,nativeTransport} and audioFormatPolicy.uploadDirectFormats. Run "openclaw doctor --fix".',
|
|
match: (value) =>
|
|
hasLegacyAccountStreamingAliases(
|
|
value,
|
|
(entry) =>
|
|
hasLegacyStreamingValue(entry) || voiceDirectUploadFormatsMigration.hasLegacy(entry),
|
|
),
|
|
},
|
|
{
|
|
path: ["channels", "qqbot", "groups"],
|
|
message:
|
|
'channels.qqbot.groups.<id>.toolPolicy is legacy and was ignored by QQBot group tool enforcement; use channels.qqbot.groups.<id>.tools instead. Run "openclaw doctor --fix".',
|
|
match: groupToolPolicyMigration.hasLegacy,
|
|
},
|
|
{
|
|
path: ["channels", "qqbot", "accounts"],
|
|
message:
|
|
'channels.qqbot.accounts.<id>.groups.<groupId>.toolPolicy is legacy and was ignored by QQBot group tool enforcement; use channels.qqbot.accounts.<id>.groups.<groupId>.tools instead. Run "openclaw doctor --fix".',
|
|
match: (value) =>
|
|
hasLegacyAccountStreamingAliases(value, (account) =>
|
|
groupToolPolicyMigration.hasLegacy(asObjectRecord(account)?.groups),
|
|
),
|
|
},
|
|
];
|
|
|
|
function normalizeQqbotEntry(params: {
|
|
entry: Record<string, unknown>;
|
|
pathPrefix: string;
|
|
changes: string[];
|
|
}): { entry: Record<string, unknown>; changed: boolean } {
|
|
let { entry, changed } = migrateStreamingValue(params);
|
|
const audioFormats = voiceDirectUploadFormatsMigration.normalize({
|
|
...params,
|
|
entry,
|
|
});
|
|
entry = audioFormats.entry;
|
|
changed ||= audioFormats.changed;
|
|
const groups = asObjectRecord(entry.groups);
|
|
if (!groups) {
|
|
return { entry, changed };
|
|
}
|
|
const migrated = groupToolPolicyMigration.normalize({
|
|
entry: groups,
|
|
pathPrefix: `${params.pathPrefix}.groups`,
|
|
changes: params.changes,
|
|
});
|
|
return migrated.changed
|
|
? { entry: { ...entry, groups: migrated.entry }, changed: true }
|
|
: { entry, changed };
|
|
}
|
|
|
|
export function normalizeCompatibilityConfig({
|
|
cfg,
|
|
}: {
|
|
cfg: OpenClawConfig;
|
|
}): ChannelDoctorConfigMutation {
|
|
return normalizeChannelConfigEntries({
|
|
cfg,
|
|
channelId: "qqbot",
|
|
normalizeEntry: normalizeQqbotEntry,
|
|
});
|
|
}
|