From 33ac355645e6cd370138f5aab58be026320bb55a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 25 Apr 2026 00:41:19 +0900 Subject: [PATCH] Preserve migration history during config migration --- src/shared/migrate-legacy-config-file.test.ts | 25 +++++++++++++++ src/shared/migrate-legacy-config-file.ts | 28 +++++++++++++++++ src/shared/migration/config-migration.test.ts | 31 +++++++++++++++++++ src/shared/migration/config-migration.ts | 12 +++++-- 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/shared/migrate-legacy-config-file.test.ts b/src/shared/migrate-legacy-config-file.test.ts index 0277b11bc..eb1c1d32b 100644 --- a/src/shared/migrate-legacy-config-file.test.ts +++ b/src/shared/migrate-legacy-config-file.test.ts @@ -35,6 +35,31 @@ describe("migrateLegacyConfigFile", () => { }) }) + describe("#given a legacy config sidecar exists", () => { + describe("#when migrating the config file", () => { + it("#then copies applied migration history to the canonical sidecar", () => { + const legacyPath = join(testDir, "oh-my-opencode.json") + const legacySidecarPath = `${legacyPath}.migrations.json` + const canonicalSidecarPath = join(testDir, "oh-my-openagent.json.migrations.json") + writeFileSync(legacyPath, '{ "agents": { "oracle": { "model": "anthropic/claude-opus-4-6" } } }') + writeFileSync( + legacySidecarPath, + JSON.stringify({ + appliedMigrations: [ + "model-version:anthropic/claude-opus-4-6->anthropic/claude-opus-4-7", + ], + }), + ) + + const result = migrateLegacyConfigFile(legacyPath) + + expect(result).toBe(true) + expect(existsSync(canonicalSidecarPath)).toBe(true) + expect(readFileSync(canonicalSidecarPath, "utf-8")).toBe(readFileSync(legacySidecarPath, "utf-8")) + }) + }) + }) + describe("#given oh-my-opencode.json exists but oh-my-openagent.json does not", () => { describe("#when migrating the config file", () => { it("#then copies to oh-my-openagent.json", () => { diff --git a/src/shared/migrate-legacy-config-file.ts b/src/shared/migrate-legacy-config-file.ts index 2affcab54..7eada47dc 100644 --- a/src/shared/migrate-legacy-config-file.ts +++ b/src/shared/migrate-legacy-config-file.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs" import { join, dirname, basename } from "node:path" import { log } from "./logger" +import { getSidecarPath } from "./migration/migrations-sidecar" import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./plugin-identity" import { writeFileAtomically } from "./write-file-atomically" @@ -42,6 +43,31 @@ function archiveLegacyConfigFile(legacyPath: string): boolean { } } +function migrateLegacySidecarFile(legacyPath: string, canonicalPath: string): boolean { + const legacySidecarPath = getSidecarPath(legacyPath) + if (!existsSync(legacySidecarPath)) return true + + const canonicalSidecarPath = getSidecarPath(canonicalPath) + if (existsSync(canonicalSidecarPath)) return true + + try { + const content = readFileSync(legacySidecarPath, "utf-8") + writeFileAtomically(canonicalSidecarPath, content) + log("[migrateLegacyConfigFile] Migrated legacy migration sidecar to canonical path", { + from: legacySidecarPath, + to: canonicalSidecarPath, + }) + return true + } catch (error) { + log("[migrateLegacyConfigFile] Failed to migrate legacy migration sidecar", { + legacySidecarPath, + canonicalSidecarPath, + error, + }) + return false + } +} + export function migrateLegacyConfigFile(legacyPath: string): boolean { if (!existsSync(legacyPath)) return false if (!basename(legacyPath).startsWith(LEGACY_CONFIG_BASENAME)) return false @@ -52,10 +78,12 @@ export function migrateLegacyConfigFile(legacyPath: string): boolean { try { const content = readFileSync(legacyPath, "utf-8") writeFileAtomically(canonicalPath, content) + const migratedSidecar = migrateLegacySidecarFile(legacyPath, canonicalPath) const archivedLegacyConfig = archiveLegacyConfigFile(legacyPath) log("[migrateLegacyConfigFile] Migrated legacy config to canonical path", { from: legacyPath, to: canonicalPath, + migratedSidecar, archivedLegacyConfig, }) return true diff --git a/src/shared/migration/config-migration.test.ts b/src/shared/migration/config-migration.test.ts index ff59d7ca3..5c41f8435 100644 --- a/src/shared/migration/config-migration.test.ts +++ b/src/shared/migration/config-migration.test.ts @@ -118,6 +118,37 @@ describe("migrateConfigFile sidecar write ordering", () => { ) expect(statSync(getSidecarPath(configPath)).isDirectory()).toBe(true) }) + + test("treats top-level appliedMigrations as migration history and does not reapply the model update", () => { + // given + const workdir = createWorkdir() + const configPath = join(workdir, "oh-my-openagent.json") + const rawConfig: Record = { + agents: { + oracle: { model: "anthropic/claude-opus-4-6" }, + }, + appliedMigrations: ["model-version:anthropic/claude-opus-4-6->anthropic/claude-opus-4-7"], + } + + writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n") + + // when + const needsWrite = migrateConfigFile(configPath, rawConfig) + + // then + expect(needsWrite).toBe(true) + expect(rawConfig.appliedMigrations).toBeUndefined() + expect((rawConfig.agents as Record>).oracle.model).toBe( + "anthropic/claude-opus-4-6", + ) + + const sidecar = JSON.parse(readFileSync(getSidecarPath(configPath), "utf-8")) as { + appliedMigrations: string[] + } + expect(sidecar.appliedMigrations).toEqual([ + "model-version:anthropic/claude-opus-4-6->anthropic/claude-opus-4-7", + ]) + }) }) describe("migrateConfigFile backup skipping", () => { diff --git a/src/shared/migration/config-migration.ts b/src/shared/migration/config-migration.ts index 792ca1083..5c0ed2d87 100644 --- a/src/shared/migration/config-migration.ts +++ b/src/shared/migration/config-migration.ts @@ -22,13 +22,18 @@ export function migrateConfigFile( // that still carry `_migrations` working without a forced reset. const sidecarMigrations = readAppliedMigrations(configPath) const inConfigMigrations = Array.isArray(copy._migrations) - ? new Set(copy._migrations as string[]) + ? new Set(copy._migrations.filter((migration): migration is string => typeof migration === "string")) + : new Set() + const inlineAppliedMigrations = Array.isArray(copy.appliedMigrations) + ? new Set(copy.appliedMigrations.filter((migration): migration is string => typeof migration === "string")) : new Set() const existingMigrations = new Set([ ...sidecarMigrations, ...inConfigMigrations, + ...inlineAppliedMigrations, ]) const hadLegacyInConfigMigrations = inConfigMigrations.size > 0 + const hadInlineAppliedMigrations = inlineAppliedMigrations.size > 0 const allNewMigrations: string[] = [] if (copy.agents && typeof copy.agents === "object") { @@ -78,12 +83,13 @@ export function migrateConfigFile( ...existingMigrations, ...newMigrationsToRecord, ]) - const shouldWriteSidecar = newMigrationsToRecord.length > 0 || hadLegacyInConfigMigrations + const shouldWriteSidecar = newMigrationsToRecord.length > 0 || hadLegacyInConfigMigrations || hadInlineAppliedMigrations if (newMigrationsToRecord.length > 0) { needsWrite = true } - if (hadLegacyInConfigMigrations) { + if (hadLegacyInConfigMigrations || hadInlineAppliedMigrations) { // Migrating state out of the config body is itself a config write. + delete copy.appliedMigrations needsWrite = true } if (shouldWriteSidecar) {