mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:40:44 +00:00
fix(doctor): migrate legacy tts enabled toggles
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Ollama: compose caller abort signals with guarded-fetch timeouts for native `/api/chat` streams, so `/stop` and early cancellation still interrupt local Ollama requests that also carry provider timeout budgets. Refs #74133. Thanks @obviyus.
|
||||
- Doctor/TTS: migrate legacy `messages.tts.enabled`, agent TTS, channel TTS, and voice-call plugin TTS toggles to `auto` mode during `openclaw doctor --fix`, matching the documented TTS config contract. Thanks @vincentkoc.
|
||||
- CLI/logs: fall back to the configured Gateway file log when implicit loopback Gateway connections close or time out before or during `logs.tail`, so `openclaw logs` still works while diagnosing local-model Gateway disconnects. Refs #74078. Thanks @sakalaboator.
|
||||
- MCP/plugins: stringify non-array plugin tool results with chat-content coercion instead of default object stringification, so MCP callers receive useful JSON/text content from plugin tools. Thanks @vincentkoc.
|
||||
- Active Memory/QMD: run QMD boot refresh through a one-shot subprocess path, preserve interactive file watching, and align watcher dependency/build ignores with QMD's scanner so gateway startup avoids arming long-lived QMD watchers. Thanks @codexGW.
|
||||
|
||||
@@ -183,6 +183,18 @@ export const DOCTOR_DEPRECATION_COMPAT_RECORDS = [
|
||||
docsPath: "/tools/tts",
|
||||
tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"],
|
||||
}),
|
||||
deprecatedCompatRecord({
|
||||
code: "doctor-tts-enabled-auto-mode",
|
||||
owner: "tts",
|
||||
introduced: "2026-04-29",
|
||||
source:
|
||||
"messages.tts.enabled, agents.*.tts.enabled, channels.*.tts.enabled, and voice-call plugin tts.enabled",
|
||||
migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts",
|
||||
replacement:
|
||||
'messages/agents/channels/plugins TTS auto mode, for example auto: "always" or auto: "off"',
|
||||
docsPath: "/tools/tts",
|
||||
tests: ["src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts"],
|
||||
}),
|
||||
deprecatedCompatRecord({
|
||||
code: "doctor-plugin-install-config-ledger",
|
||||
owner: "plugin",
|
||||
|
||||
@@ -83,6 +83,88 @@ describe("legacy migrate provider-shaped config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("moves legacy tts enabled toggles to auto mode in known config locations", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
tts: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "voice-agent",
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "tagged",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
},
|
||||
accounts: {
|
||||
primary: {
|
||||
tts: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": {
|
||||
config: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.changes).toEqual([
|
||||
'Moved messages.tts.enabled → messages.tts.auto "always".',
|
||||
'Moved agents.defaults.tts.enabled → agents.defaults.tts.auto "off".',
|
||||
"Removed agents.list[0].tts.enabled because agents.list[0].tts.auto is already set.",
|
||||
'Moved channels.discord.tts.enabled → channels.discord.tts.auto "always".',
|
||||
'Moved channels.discord.accounts.primary.tts.enabled → channels.discord.accounts.primary.tts.auto "off".',
|
||||
'Moved plugins.entries.voice-call.config.tts.enabled → plugins.entries.voice-call.config.tts.auto "always".',
|
||||
]);
|
||||
expect(res.config).toMatchObject({
|
||||
messages: { tts: { auto: "always" } },
|
||||
agents: {
|
||||
defaults: { tts: { auto: "off" } },
|
||||
list: [{ id: "voice-agent", tts: { auto: "tagged" } }],
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
tts: { auto: "always" },
|
||||
accounts: { primary: { tts: { auto: "off" } } },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": {
|
||||
config: {
|
||||
tts: { auto: "always" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("moves plugins.entries.voice-call.config.tts.<provider> keys into providers", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
plugins: {
|
||||
|
||||
@@ -44,6 +44,57 @@ function hasLegacyPluginEntryTtsProviderKeys(value: unknown): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
function hasLegacyTtsEnabled(value: unknown): boolean {
|
||||
return typeof getRecord(value)?.enabled === "boolean";
|
||||
}
|
||||
|
||||
function hasLegacyTtsEnabledInAgentLocations(value: unknown): boolean {
|
||||
const agents = getRecord(value);
|
||||
if (hasLegacyTtsEnabled(getRecord(getRecord(agents?.defaults)?.tts))) {
|
||||
return true;
|
||||
}
|
||||
const agentList = Array.isArray(agents?.list) ? agents.list : [];
|
||||
return agentList.some((entry) => hasLegacyTtsEnabled(getRecord(getRecord(entry)?.tts)));
|
||||
}
|
||||
|
||||
function hasLegacyTtsEnabledInChannelLocations(value: unknown): boolean {
|
||||
const channels = getRecord(value);
|
||||
for (const [channelId, channelValue] of Object.entries(channels ?? {})) {
|
||||
if (isBlockedObjectKey(channelId)) {
|
||||
continue;
|
||||
}
|
||||
const channel = getRecord(channelValue);
|
||||
if (hasLegacyTtsEnabled(getRecord(channel?.tts))) {
|
||||
return true;
|
||||
}
|
||||
const accounts = getRecord(channel?.accounts);
|
||||
for (const [accountId, accountValue] of Object.entries(accounts ?? {})) {
|
||||
if (isBlockedObjectKey(accountId)) {
|
||||
continue;
|
||||
}
|
||||
if (hasLegacyTtsEnabled(getRecord(getRecord(accountValue)?.tts))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasLegacyTtsEnabledInPluginLocations(value: unknown): boolean {
|
||||
const entries = getRecord(value);
|
||||
if (!entries) {
|
||||
return false;
|
||||
}
|
||||
return Object.entries(entries).some(([pluginId, entryValue]) => {
|
||||
if (isBlockedObjectKey(pluginId) || !LEGACY_TTS_PLUGIN_IDS.has(pluginId)) {
|
||||
return false;
|
||||
}
|
||||
const entry = getRecord(entryValue);
|
||||
const config = getRecord(entry?.config);
|
||||
return hasLegacyTtsEnabled(getRecord(config?.tts));
|
||||
});
|
||||
}
|
||||
|
||||
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
|
||||
const providers = getRecord(tts.providers) ?? {};
|
||||
tts.providers = providers;
|
||||
@@ -121,7 +172,73 @@ function migrateLegacyTtsConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const LEGACY_TTS_RULES: LegacyConfigRule[] = [
|
||||
function migrateLegacyTtsEnabled(
|
||||
tts: Record<string, unknown> | null | undefined,
|
||||
pathLabel: string,
|
||||
changes: string[],
|
||||
): void {
|
||||
if (!tts || typeof tts.enabled !== "boolean") {
|
||||
return;
|
||||
}
|
||||
const nextAuto = tts.enabled ? "always" : "off";
|
||||
delete tts.enabled;
|
||||
if (typeof tts.auto === "string" && tts.auto.trim()) {
|
||||
changes.push(`Removed ${pathLabel}.enabled because ${pathLabel}.auto is already set.`);
|
||||
return;
|
||||
}
|
||||
tts.auto = nextAuto;
|
||||
changes.push(`Moved ${pathLabel}.enabled → ${pathLabel}.auto "${nextAuto}".`);
|
||||
}
|
||||
|
||||
function visitKnownTtsConfigLocations(
|
||||
raw: Record<string, unknown>,
|
||||
visit: (tts: Record<string, unknown> | null | undefined, pathLabel: string) => void,
|
||||
): void {
|
||||
const messages = getRecord(raw.messages);
|
||||
visit(getRecord(messages?.tts), "messages.tts");
|
||||
|
||||
const agents = getRecord(raw.agents);
|
||||
const agentDefaults = getRecord(agents?.defaults);
|
||||
visit(getRecord(agentDefaults?.tts), "agents.defaults.tts");
|
||||
|
||||
const agentList = Array.isArray(agents?.list) ? agents.list : [];
|
||||
agentList.forEach((entry, index) => {
|
||||
const agent = getRecord(entry);
|
||||
visit(getRecord(agent?.tts), `agents.list[${index}].tts`);
|
||||
});
|
||||
|
||||
const channels = getRecord(raw.channels);
|
||||
for (const [channelId, channelValue] of Object.entries(channels ?? {})) {
|
||||
if (isBlockedObjectKey(channelId)) {
|
||||
continue;
|
||||
}
|
||||
const channel = getRecord(channelValue);
|
||||
visit(getRecord(channel?.tts), `channels.${channelId}.tts`);
|
||||
const accounts = getRecord(channel?.accounts);
|
||||
for (const [accountId, accountValue] of Object.entries(accounts ?? {})) {
|
||||
if (isBlockedObjectKey(accountId)) {
|
||||
continue;
|
||||
}
|
||||
visit(
|
||||
getRecord(getRecord(accountValue)?.tts),
|
||||
`channels.${channelId}.accounts.${accountId}.tts`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = getRecord(raw.plugins);
|
||||
const pluginEntries = getRecord(plugins?.entries);
|
||||
for (const [pluginId, entryValue] of Object.entries(pluginEntries ?? {})) {
|
||||
if (isBlockedObjectKey(pluginId) || !LEGACY_TTS_PLUGIN_IDS.has(pluginId)) {
|
||||
continue;
|
||||
}
|
||||
const entry = getRecord(entryValue);
|
||||
const config = getRecord(entry?.config);
|
||||
visit(getRecord(config?.tts), `plugins.entries.${pluginId}.config.tts`);
|
||||
}
|
||||
}
|
||||
|
||||
const LEGACY_TTS_PROVIDER_RULES: LegacyConfigRule[] = [
|
||||
{
|
||||
path: ["messages", "tts"],
|
||||
message:
|
||||
@@ -136,11 +253,36 @@ const LEGACY_TTS_RULES: LegacyConfigRule[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const LEGACY_TTS_ENABLED_RULES: LegacyConfigRule[] = [
|
||||
{
|
||||
path: ["messages", "tts"],
|
||||
message: 'messages.tts.enabled is legacy; use messages.tts.auto. Run "openclaw doctor --fix".',
|
||||
match: (value) => hasLegacyTtsEnabled(value),
|
||||
},
|
||||
{
|
||||
path: ["agents"],
|
||||
message: 'agents.*.tts.enabled is legacy; use agents.*.tts.auto. Run "openclaw doctor --fix".',
|
||||
match: (value) => hasLegacyTtsEnabledInAgentLocations(value),
|
||||
},
|
||||
{
|
||||
path: ["channels"],
|
||||
message:
|
||||
'channels.*.tts.enabled is legacy; use channels.*.tts.auto. Run "openclaw doctor --fix".',
|
||||
match: (value) => hasLegacyTtsEnabledInChannelLocations(value),
|
||||
},
|
||||
{
|
||||
path: ["plugins", "entries"],
|
||||
message:
|
||||
'plugins.entries.voice-call.config.tts.enabled is legacy; use plugins.entries.voice-call.config.tts.auto. Run "openclaw doctor --fix".',
|
||||
match: (value) => hasLegacyTtsEnabledInPluginLocations(value),
|
||||
},
|
||||
];
|
||||
|
||||
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS: LegacyConfigMigrationSpec[] = [
|
||||
defineLegacyConfigMigration({
|
||||
id: "tts.providers-generic-shape",
|
||||
describe: "Move legacy bundled TTS config keys into messages.tts.providers",
|
||||
legacyRules: LEGACY_TTS_RULES,
|
||||
legacyRules: LEGACY_TTS_PROVIDER_RULES,
|
||||
apply: (raw, changes) => {
|
||||
const messages = getRecord(raw.messages);
|
||||
migrateLegacyTtsConfig(getRecord(messages?.tts), "messages.tts", changes);
|
||||
@@ -164,4 +306,14 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS: LegacyConfigMigrationSpec[] =
|
||||
}
|
||||
},
|
||||
}),
|
||||
defineLegacyConfigMigration({
|
||||
id: "tts.enabled-auto-mode",
|
||||
describe: "Move legacy TTS enabled toggles to auto mode",
|
||||
legacyRules: LEGACY_TTS_ENABLED_RULES,
|
||||
apply: (raw, changes) => {
|
||||
visitKnownTtsConfigLocations(raw, (tts, pathLabel) =>
|
||||
migrateLegacyTtsEnabled(tts, pathLabel, changes),
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
16
src/cron/cron-protocol-schema.test.ts
Normal file
16
src/cron/cron-protocol-schema.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CronJobStateSchema } from "../gateway/protocol/schema.js";
|
||||
|
||||
type SchemaLike = {
|
||||
properties?: Record<string, unknown>;
|
||||
deprecated?: boolean;
|
||||
};
|
||||
|
||||
describe("cron protocol schema", () => {
|
||||
it("marks the legacy lastStatus alias deprecated", () => {
|
||||
const properties = (CronJobStateSchema as SchemaLike).properties ?? {};
|
||||
const lastStatus = properties.lastStatus as SchemaLike | undefined;
|
||||
expect(lastStatus).toBeDefined();
|
||||
expect(lastStatus?.deprecated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,15 @@ const CronSessionTargetSchema = Type.Union([
|
||||
Type.String({ pattern: "^session:.+" }),
|
||||
]);
|
||||
const CronWakeModeSchema = Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]);
|
||||
const CronRunStatusSchema = Type.Union([
|
||||
Type.Literal("ok"),
|
||||
Type.Literal("error"),
|
||||
Type.Literal("skipped"),
|
||||
]);
|
||||
function cronRunStatusSchema(options: Record<string, unknown> = {}) {
|
||||
return Type.Union([Type.Literal("ok"), Type.Literal("error"), Type.Literal("skipped")], options);
|
||||
}
|
||||
|
||||
const CronRunStatusSchema = cronRunStatusSchema();
|
||||
const DeprecatedCronRunStatusSchema = cronRunStatusSchema({
|
||||
deprecated: true,
|
||||
description: "Deprecated alias for lastRunStatus.",
|
||||
});
|
||||
const CronSortDirSchema = Type.Union([Type.Literal("asc"), Type.Literal("desc")]);
|
||||
const CronJobsEnabledFilterSchema = Type.Union([
|
||||
Type.Literal("all"),
|
||||
@@ -239,7 +243,7 @@ export const CronJobStateSchema = Type.Object(
|
||||
runningAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
lastRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
lastRunStatus: Type.Optional(CronRunStatusSchema),
|
||||
lastStatus: Type.Optional(CronRunStatusSchema),
|
||||
lastStatus: Type.Optional(DeprecatedCronRunStatusSchema),
|
||||
lastError: Type.Optional(Type.String()),
|
||||
lastErrorReason: Type.Optional(CronFailoverReasonSchema),
|
||||
lastDurationMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
|
||||
Reference in New Issue
Block a user