refactor(config): dedupe legacy migration metadata

This commit is contained in:
Peter Steinberger
2026-03-27 02:37:44 +00:00
parent 66e7e29219
commit 9df9bd436e
6 changed files with 180 additions and 155 deletions

View File

@@ -1,8 +1,9 @@
import {
defineLegacyConfigMigration,
ensureRecord,
getRecord,
type LegacyConfigMigration,
mapLegacyAudioTranscription,
type LegacyConfigMigrationSpec,
} from "./legacy.shared.js";
function applyLegacyAudioTranscriptionModel(params: {
@@ -31,8 +32,8 @@ function applyLegacyAudioTranscriptionModel(params: {
params.changes.push(params.alreadySetMessage);
}
export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
{
export const LEGACY_CONFIG_MIGRATIONS_AUDIO: LegacyConfigMigrationSpec[] = [
defineLegacyConfigMigration({
id: "audio.transcription-v2",
describe: "Move audio.transcription to tools.media.audio.models",
apply: (raw, changes) => {
@@ -56,5 +57,5 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
raw.audio = audio;
}
},
},
}),
];

View File

@@ -6,14 +6,30 @@ import {
resolveSlackStreamingMode,
resolveTelegramPreviewStreamMode,
} from "./discord-preview-streaming.js";
import { getRecord, type LegacyConfigMigration } from "./legacy.shared.js";
import {
defineLegacyConfigMigration,
getRecord,
type LegacyConfigMigrationSpec,
type LegacyConfigRule,
} from "./legacy.shared.js";
function hasOwnKey(target: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(target, key);
}
function escapeControlForLog(value: string): string {
return value.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
function hasLegacyThreadBindingTtl(value: unknown): boolean {
const threadBindings = getRecord(value);
return Boolean(threadBindings && hasOwnKey(threadBindings, "ttlHours"));
}
function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean {
const accounts = getRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((entry) =>
hasLegacyThreadBindingTtl(getRecord(entry)?.threadBindings),
);
}
function migrateThreadBindingsTtlHoursForPath(params: {
@@ -45,11 +61,33 @@ function migrateThreadBindingsTtlHoursForPath(params: {
return true;
}
export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
const THREAD_BINDING_RULES: LegacyConfigRule[] = [
{
path: ["session", "threadBindings"],
message:
"session.threadBindings.ttlHours was renamed to session.threadBindings.idleHours (auto-migrated on load).",
match: (value) => hasLegacyThreadBindingTtl(value),
},
{
path: ["channels", "discord", "threadBindings"],
message:
"channels.discord.threadBindings.ttlHours was renamed to channels.discord.threadBindings.idleHours (auto-migrated on load).",
match: (value) => hasLegacyThreadBindingTtl(value),
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.threadBindings.ttlHours was renamed to channels.discord.accounts.<id>.threadBindings.idleHours (auto-migrated on load).",
match: (value) => hasLegacyThreadBindingTtlInAccounts(value),
},
];
export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
defineLegacyConfigMigration({
id: "thread-bindings.ttlHours->idleHours",
describe:
"Move legacy threadBindings.ttlHours keys to threadBindings.idleHours (session + channels.discord)",
legacyRules: THREAD_BINDING_RULES,
apply: (raw, changes) => {
const session = getRecord(raw.session);
if (session) {
@@ -93,8 +131,8 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
channels.discord = discord;
raw.channels = channels;
},
},
{
}),
defineLegacyConfigMigration({
id: "channels.streaming-keys->channels.streaming",
describe:
"Normalize legacy streaming keys to channels.<provider>.streaming (Telegram/Discord/Slack)",
@@ -196,45 +234,5 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
migrateProvider("discord");
migrateProvider("slack");
},
},
{
id: "gateway.bind.host-alias->bind-mode",
describe: "Normalize gateway.bind host aliases to supported bind modes",
apply: (raw, changes) => {
const gateway = getRecord(raw.gateway);
if (!gateway) {
return;
}
const bindRaw = gateway.bind;
if (typeof bindRaw !== "string") {
return;
}
const normalized = bindRaw.trim().toLowerCase();
let mapped: "lan" | "loopback" | undefined;
if (
normalized === "0.0.0.0" ||
normalized === "::" ||
normalized === "[::]" ||
normalized === "*"
) {
mapped = "lan";
} else if (
normalized === "127.0.0.1" ||
normalized === "localhost" ||
normalized === "::1" ||
normalized === "[::1]"
) {
mapped = "loopback";
}
if (!mapped || normalized === mapped) {
return;
}
gateway.bind = mapped;
raw.gateway = gateway;
changes.push(`Normalized gateway.bind "${escapeControlForLog(bindRaw)}" → "${mapped}".`);
},
},
}),
];

View File

@@ -5,10 +5,12 @@ import {
resolveGatewayPortWithDefault,
} from "./gateway-control-ui-origins.js";
import {
defineLegacyConfigMigration,
ensureRecord,
getRecord,
type LegacyConfigMigration,
mergeMissing,
type LegacyConfigMigrationSpec,
type LegacyConfigRule,
} from "./legacy.shared.js";
import { DEFAULT_GATEWAY_PORT } from "./paths.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
@@ -32,6 +34,39 @@ const AGENT_HEARTBEAT_KEYS = new Set([
const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]);
function isLegacyGatewayBindHostAlias(value: unknown): boolean {
if (typeof value !== "string") {
return false;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return false;
}
if (
normalized === "auto" ||
normalized === "loopback" ||
normalized === "lan" ||
normalized === "tailnet" ||
normalized === "custom"
) {
return false;
}
return (
normalized === "0.0.0.0" ||
normalized === "::" ||
normalized === "[::]" ||
normalized === "*" ||
normalized === "127.0.0.1" ||
normalized === "localhost" ||
normalized === "::1" ||
normalized === "[::1]"
);
}
function escapeControlForLog(value: string): string {
return value.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
}
function splitLegacyHeartbeat(legacyHeartbeat: Record<string, unknown>): {
agentHeartbeat: Record<string, unknown> | null;
channelHeartbeat: Record<string, unknown> | null;
@@ -140,12 +175,28 @@ function migrateLegacyTtsConfig(
}
}
// NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed.
const MEMORY_SEARCH_RULE: LegacyConfigRule = {
path: ["memorySearch"],
message:
"top-level memorySearch was moved; use agents.defaults.memorySearch instead (auto-migrated on load).",
};
// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
const GATEWAY_BIND_RULE: LegacyConfigRule = {
path: ["gateway", "bind"],
message:
"gateway.bind host aliases (for example 0.0.0.0/localhost) are legacy; use bind modes (lan/loopback/custom/tailnet/auto) instead (auto-migrated on load).",
match: (value) => isLegacyGatewayBindHostAlias(value),
requireSourceLiteral: true,
};
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
{
const HEARTBEAT_RULE: LegacyConfigRule = {
path: ["heartbeat"],
message:
"top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
};
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [
defineLegacyConfigMigration({
// v2026.2.26 added a startup guard requiring gateway.controlUi.allowedOrigins (or the
// host-header fallback flag) for any non-loopback bind. The setup wizard was updated
// to seed this for new installs, but existing bind=lan/bind=custom installs that upgrade
@@ -188,10 +239,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
"Required since v2026.2.26. Add other machine origins to gateway.controlUi.allowedOrigins if needed.",
);
},
},
{
}),
defineLegacyConfigMigration({
id: "memorySearch->agents.defaults.memorySearch",
describe: "Move top-level memorySearch to agents.defaults.memorySearch",
legacyRules: [MEMORY_SEARCH_RULE],
apply: (raw, changes) => {
const legacyMemorySearch = getRecord(raw.memorySearch);
if (!legacyMemorySearch) {
@@ -210,8 +262,49 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
});
delete raw.memorySearch;
},
},
{
}),
defineLegacyConfigMigration({
id: "gateway.bind.host-alias->bind-mode",
describe: "Normalize gateway.bind host aliases to supported bind modes",
legacyRules: [GATEWAY_BIND_RULE],
apply: (raw, changes) => {
const gateway = getRecord(raw.gateway);
if (!gateway) {
return;
}
const bindRaw = gateway.bind;
if (typeof bindRaw !== "string") {
return;
}
const normalized = bindRaw.trim().toLowerCase();
let mapped: "lan" | "loopback" | undefined;
if (
normalized === "0.0.0.0" ||
normalized === "::" ||
normalized === "[::]" ||
normalized === "*"
) {
mapped = "lan";
} else if (
normalized === "127.0.0.1" ||
normalized === "localhost" ||
normalized === "::1" ||
normalized === "[::1]"
) {
mapped = "loopback";
}
if (!mapped || normalized === mapped) {
return;
}
gateway.bind = mapped;
raw.gateway = gateway;
changes.push(`Normalized gateway.bind "${escapeControlForLog(bindRaw)}" → "${mapped}".`);
},
}),
defineLegacyConfigMigration({
id: "tts.providers-generic-shape",
describe: "Move legacy bundled TTS config keys into messages.tts.providers",
apply: (raw, changes) => {
@@ -240,10 +333,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
);
}
},
},
{
}),
defineLegacyConfigMigration({
id: "heartbeat->agents.defaults.heartbeat",
describe: "Move top-level heartbeat to agents.defaults.heartbeat/channels.defaults.heartbeat",
legacyRules: [HEARTBEAT_RULE],
apply: (raw, changes) => {
const legacyHeartbeat = getRecord(raw.heartbeat);
if (!legacyHeartbeat) {
@@ -283,5 +377,5 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
}
delete raw.heartbeat;
},
},
}),
];

View File

@@ -1,9 +1,17 @@
import { LEGACY_CONFIG_MIGRATIONS_PART_1 } from "./legacy.migrations.part-1.js";
import { LEGACY_CONFIG_MIGRATIONS_PART_2 } from "./legacy.migrations.part-2.js";
import { LEGACY_CONFIG_MIGRATIONS_PART_3 } from "./legacy.migrations.part-3.js";
import { LEGACY_CONFIG_MIGRATIONS_AUDIO } from "./legacy.migrations.audio.js";
import { LEGACY_CONFIG_MIGRATIONS_CHANNELS } from "./legacy.migrations.channels.js";
import { LEGACY_CONFIG_MIGRATIONS_RUNTIME } from "./legacy.migrations.runtime.js";
export const LEGACY_CONFIG_MIGRATIONS = [
...LEGACY_CONFIG_MIGRATIONS_PART_1,
...LEGACY_CONFIG_MIGRATIONS_PART_2,
...LEGACY_CONFIG_MIGRATIONS_PART_3,
const LEGACY_CONFIG_MIGRATION_SPECS = [
...LEGACY_CONFIG_MIGRATIONS_CHANNELS,
...LEGACY_CONFIG_MIGRATIONS_AUDIO,
...LEGACY_CONFIG_MIGRATIONS_RUNTIME,
];
export const LEGACY_CONFIG_MIGRATIONS = LEGACY_CONFIG_MIGRATION_SPECS.map(
({ legacyRules: _legacyRules, ...migration }) => migration,
);
export const LEGACY_CONFIG_MIGRATION_RULES = LEGACY_CONFIG_MIGRATION_SPECS.flatMap(
(migration) => migration.legacyRules ?? [],
);

View File

@@ -1,85 +1 @@
import type { LegacyConfigRule } from "./legacy.shared.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function hasLegacyThreadBindingTtl(value: unknown): boolean {
return isRecord(value) && Object.prototype.hasOwnProperty.call(value, "ttlHours");
}
function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return Object.values(value).some((entry) =>
hasLegacyThreadBindingTtl(isRecord(entry) ? entry.threadBindings : undefined),
);
}
function isLegacyGatewayBindHostAlias(value: unknown): boolean {
if (typeof value !== "string") {
return false;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return false;
}
if (
normalized === "auto" ||
normalized === "loopback" ||
normalized === "lan" ||
normalized === "tailnet" ||
normalized === "custom"
) {
return false;
}
return (
normalized === "0.0.0.0" ||
normalized === "::" ||
normalized === "[::]" ||
normalized === "*" ||
normalized === "127.0.0.1" ||
normalized === "localhost" ||
normalized === "::1" ||
normalized === "[::1]"
);
}
export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
{
path: ["session", "threadBindings"],
message:
"session.threadBindings.ttlHours was renamed to session.threadBindings.idleHours (auto-migrated on load).",
match: (value) => hasLegacyThreadBindingTtl(value),
},
{
path: ["channels", "discord", "threadBindings"],
message:
"channels.discord.threadBindings.ttlHours was renamed to channels.discord.threadBindings.idleHours (auto-migrated on load).",
match: (value) => hasLegacyThreadBindingTtl(value),
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.threadBindings.ttlHours was renamed to channels.discord.accounts.<id>.threadBindings.idleHours (auto-migrated on load).",
match: (value) => hasLegacyThreadBindingTtlInAccounts(value),
},
{
path: ["memorySearch"],
message:
"top-level memorySearch was moved; use agents.defaults.memorySearch instead (auto-migrated on load).",
},
{
path: ["gateway", "bind"],
message:
"gateway.bind host aliases (for example 0.0.0.0/localhost) are legacy; use bind modes (lan/loopback/custom/tailnet/auto) instead (auto-migrated on load).",
match: (value) => isLegacyGatewayBindHostAlias(value),
requireSourceLiteral: true,
},
{
path: ["heartbeat"],
message:
"top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
},
];
export { LEGACY_CONFIG_MIGRATION_RULES as LEGACY_CONFIG_RULES } from "./legacy.migrations.js";

View File

@@ -13,6 +13,10 @@ export type LegacyConfigMigration = {
apply: (raw: Record<string, unknown>, changes: string[]) => void;
};
export type LegacyConfigMigrationSpec = LegacyConfigMigration & {
legacyRules?: LegacyConfigRule[];
};
import { isSafeExecutableValue } from "../infra/exec-safety.js";
import { isRecord } from "../utils.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
@@ -131,3 +135,7 @@ export const ensureAgentEntry = (list: unknown[], id: string): Record<string, un
list.push(created);
return created;
};
export const defineLegacyConfigMigration = (
migration: LegacyConfigMigrationSpec,
): LegacyConfigMigrationSpec => migration;