Merge pull request #3262 from code-yeongyu/fix/migration-sidecar
fix(migration): track applied migrations in sidecar so user reverts stick
This commit is contained in:
+169
-44
@@ -321,6 +321,18 @@ describe("migrateHookNames", () => {
|
||||
describe("migrateConfigFile", () => {
|
||||
const testConfigPath = "/tmp/nonexistent-path-for-test.json"
|
||||
|
||||
// Tests in this block share a single config path and do not write a real
|
||||
// config file, but migrateConfigFile now persists migration tracking to a
|
||||
// sidecar next to the config (#3263). Clear the sidecar between tests so
|
||||
// state from an earlier test does not bleed into the next one.
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.unlinkSync(`${testConfigPath}.migrations.json`)
|
||||
} catch {
|
||||
// ignore — sidecar may not exist
|
||||
}
|
||||
})
|
||||
|
||||
test("migrates experimental.hashline_edit to top-level hashline_edit", () => {
|
||||
// given: Config with legacy experimental.hashline_edit
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
@@ -790,8 +802,8 @@ describe("migrateConfigFile _migrations tracking", () => {
|
||||
fs.rmSync(tmpDir, { recursive: true })
|
||||
})
|
||||
|
||||
test("preserves existing _migrations and appends new ones", () => {
|
||||
// given: Config with existing migration history and a new migratable model
|
||||
test("migrates legacy in-config _migrations into the sidecar and appends new migrations (#3263)", () => {
|
||||
// given: Config with an existing legacy in-config _migrations history and a new migratable model
|
||||
const tmpDir = fs.mkdtempSync("/tmp/migration-test-")
|
||||
const configPath = `${tmpDir}/oh-my-opencode.json`
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
@@ -804,12 +816,17 @@ describe("migrateConfigFile _migrations tracking", () => {
|
||||
// when: Migrate config file
|
||||
const result = migrateConfigFile(configPath, rawConfig)
|
||||
|
||||
// then: New migration appended, old one preserved
|
||||
// then: The config body has _migrations stripped. The full history
|
||||
// (legacy + new) is written to the sidecar file exactly once.
|
||||
expect(result).toBe(true)
|
||||
expect(rawConfig._migrations).toEqual([
|
||||
expect(rawConfig._migrations).toBeUndefined()
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).prometheus.model).toBe("anthropic/claude-opus-4-6")
|
||||
|
||||
const sidecar = JSON.parse(fs.readFileSync(`${configPath}.migrations.json`, "utf-8"))
|
||||
expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([
|
||||
"model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
])
|
||||
]))
|
||||
|
||||
// cleanup
|
||||
fs.rmSync(tmpDir, { recursive: true })
|
||||
@@ -1263,7 +1280,7 @@ describe("migrateModelVersions with applied migrations", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateConfigFile with _migrations tracking", () => {
|
||||
describe("migrateConfigFile with migration tracking via sidecar (#3263)", () => {
|
||||
const cleanupPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1276,72 +1293,180 @@ describe("migrateConfigFile with _migrations tracking", () => {
|
||||
cleanupPaths.length = 0
|
||||
})
|
||||
|
||||
test("records new migrations in _migrations field", () => {
|
||||
// given: Config with old model, no _migrations field
|
||||
const testConfigPath = "/tmp/test-config-migrations-1.json"
|
||||
function tempConfigPath(label: string): string {
|
||||
const workdir = fs.mkdtempSync(`/tmp/omo-migration-${label}-`)
|
||||
cleanupPaths.push(workdir)
|
||||
return path.join(workdir, "oh-my-openagent.json")
|
||||
}
|
||||
|
||||
function sidecarPath(configPath: string): string {
|
||||
return `${configPath}.migrations.json`
|
||||
}
|
||||
|
||||
test("does not emit migration history when no migration applies", () => {
|
||||
// given: Config with a model that does not appear in MODEL_VERSION_MAP
|
||||
const testConfigPath = tempConfigPath("no-op")
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
sisyphus: { model: "openai/gpt-5.4-codex" },
|
||||
},
|
||||
}
|
||||
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
||||
cleanupPaths.push(testConfigPath)
|
||||
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// then: gpt-5.4-codex should not create migration history
|
||||
expect(needsWrite).toBe(false)
|
||||
expect(rawConfig._migrations).toBeUndefined()
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).sisyphus.model).toBe("openai/gpt-5.4-codex")
|
||||
expect(fs.existsSync(sidecarPath(testConfigPath))).toBe(false)
|
||||
})
|
||||
|
||||
test("skips re-applying already-recorded migrations", () => {
|
||||
// given: Config with old model but migration already in _migrations
|
||||
const testConfigPath = "/tmp/test-config-migrations-2.json"
|
||||
test("writes applied migrations to sidecar instead of leaving them on the config", () => {
|
||||
// given: Config that needs a real model migration and has no prior history
|
||||
const testConfigPath = tempConfigPath("sidecar-write")
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
sisyphus: { model: "openai/gpt-5.4-codex" },
|
||||
},
|
||||
_migrations: ["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"],
|
||||
}
|
||||
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
||||
cleanupPaths.push(testConfigPath)
|
||||
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// then: Should not migrate (user reverted)
|
||||
expect(needsWrite).toBe(false)
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).sisyphus.model).toBe("openai/gpt-5.4-codex")
|
||||
expect(rawConfig._migrations).toEqual(["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"])
|
||||
})
|
||||
|
||||
test("preserves existing _migrations and appends new ones", () => {
|
||||
// given: Config with multiple old models, partial migration history
|
||||
const testConfigPath = "/tmp/test-config-migrations-3.json"
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
sisyphus: { model: "openai/gpt-5.4-codex" },
|
||||
oracle: { model: "anthropic/claude-opus-4-5" },
|
||||
},
|
||||
_migrations: ["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"],
|
||||
}
|
||||
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
||||
cleanupPaths.push(testConfigPath)
|
||||
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// then: Should skip sisyphus, migrate oracle, append to _migrations
|
||||
expect(needsWrite).toBe(true)
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).sisyphus.model).toBe("openai/gpt-5.4-codex")
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(rawConfig._migrations).toEqual([
|
||||
"model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex",
|
||||
expect(rawConfig._migrations).toBeUndefined()
|
||||
|
||||
const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8"))
|
||||
expect(sidecar.appliedMigrations).toEqual([
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
])
|
||||
})
|
||||
|
||||
test("skips re-applying a migration that is recorded in the sidecar even if the user edited _migrations away", () => {
|
||||
// This is the core #3263 regression: a user auto-migrated from
|
||||
// gpt-5.3-codex to gpt-5.4, reverted to gpt-5.3-codex by hand, and
|
||||
// deleted _migrations in the process. Without the sidecar their
|
||||
// revert was clobbered on every startup.
|
||||
const testConfigPath = tempConfigPath("sidecar-revert")
|
||||
fs.writeFileSync(
|
||||
sidecarPath(testConfigPath),
|
||||
JSON.stringify({
|
||||
appliedMigrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"],
|
||||
}),
|
||||
)
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
oracle: { model: "openai/gpt-5.3-codex" },
|
||||
},
|
||||
}
|
||||
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
||||
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
expect(needsWrite).toBe(false)
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("openai/gpt-5.3-codex")
|
||||
expect(rawConfig._migrations).toBeUndefined()
|
||||
})
|
||||
|
||||
test("mirrors legacy in-config _migrations into the sidecar and then strips the field", () => {
|
||||
// BC path: configs written by older OMO versions still carry the
|
||||
// legacy _migrations field in the JSON body. On the next startup we
|
||||
// must copy that history into the new sidecar and remove the field
|
||||
// from the config so the migration tracking lives in exactly one
|
||||
// place from then on.
|
||||
const testConfigPath = tempConfigPath("bc-mirror")
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
oracle: { model: "openai/gpt-5.3-codex" },
|
||||
},
|
||||
_migrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"],
|
||||
}
|
||||
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
||||
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// needsWrite is true because we rewrote the config to drop _migrations
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig._migrations).toBeUndefined()
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("openai/gpt-5.3-codex")
|
||||
|
||||
const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8"))
|
||||
expect(sidecar.appliedMigrations).toEqual([
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
])
|
||||
})
|
||||
|
||||
test("unions sidecar and legacy _migrations entries, deduplicating", () => {
|
||||
// Defensive case: a config written by two different OMO versions
|
||||
// could end up with an entry in _migrations that is also in the
|
||||
// sidecar. The merged set should be deduplicated and the config
|
||||
// should not be re-migrated.
|
||||
const testConfigPath = tempConfigPath("sidecar-union")
|
||||
fs.writeFileSync(
|
||||
sidecarPath(testConfigPath),
|
||||
JSON.stringify({
|
||||
appliedMigrations: [
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
],
|
||||
}),
|
||||
)
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
oracle: { model: "anthropic/claude-opus-4-5" },
|
||||
},
|
||||
_migrations: ["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"],
|
||||
}
|
||||
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
||||
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// needsWrite because the legacy _migrations field was stripped
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig._migrations).toBeUndefined()
|
||||
// The reverted opus-4-5 value must be preserved
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).oracle.model).toBe("anthropic/claude-opus-4-5")
|
||||
|
||||
const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8"))
|
||||
expect(sidecar.appliedMigrations).toEqual([
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
])
|
||||
})
|
||||
|
||||
test("appends new migrations to the sidecar when partial history exists", () => {
|
||||
// Scenario: sidecar already has one migration, a second model still
|
||||
// needs to be migrated. The new migration should be recorded and the
|
||||
// already-applied one preserved.
|
||||
const testConfigPath = tempConfigPath("sidecar-append")
|
||||
fs.writeFileSync(
|
||||
sidecarPath(testConfigPath),
|
||||
JSON.stringify({
|
||||
appliedMigrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"],
|
||||
}),
|
||||
)
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
codex: { model: "openai/gpt-5.3-codex" },
|
||||
claude: { model: "anthropic/claude-opus-4-5" },
|
||||
},
|
||||
}
|
||||
fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2))
|
||||
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
expect(needsWrite).toBe(true)
|
||||
// codex was reverted, must stay
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).codex.model).toBe("openai/gpt-5.3-codex")
|
||||
// claude migrates
|
||||
expect((rawConfig.agents as Record<string, Record<string, unknown>>).claude.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(rawConfig._migrations).toBeUndefined()
|
||||
|
||||
const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8"))
|
||||
expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { writeFileAtomically } from "../write-file-atomically"
|
||||
import { AGENT_NAME_MAP, migrateAgentNames } from "./agent-names"
|
||||
import { migrateHookNames } from "./hook-names"
|
||||
import { migrateModelVersions } from "./model-versions"
|
||||
import { readAppliedMigrations, writeAppliedMigrations } from "./migrations-sidecar"
|
||||
|
||||
export function migrateConfigFile(
|
||||
configPath: string,
|
||||
@@ -12,10 +13,22 @@ export function migrateConfigFile(
|
||||
const copy = structuredClone(rawConfig)
|
||||
let needsWrite = false
|
||||
|
||||
// Load previously applied migrations
|
||||
const existingMigrations = Array.isArray(copy._migrations)
|
||||
// Load previously applied migrations from BOTH the legacy in-config
|
||||
// `_migrations` field AND the external sidecar file. The sidecar is the
|
||||
// new source of truth because users were editing the config file to
|
||||
// revert auto-migrated values and accidentally dropping the `_migrations`
|
||||
// field in the process, which produced an infinite migration loop on
|
||||
// every startup (#3263). Reading from both sources keeps old configs
|
||||
// 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<string>()
|
||||
const existingMigrations = new Set<string>([
|
||||
...sidecarMigrations,
|
||||
...inConfigMigrations,
|
||||
])
|
||||
const hadLegacyInConfigMigrations = inConfigMigrations.size > 0
|
||||
const allNewMigrations: string[] = []
|
||||
|
||||
if (copy.agents && typeof copy.agents === "object") {
|
||||
@@ -54,13 +67,30 @@ export function migrateConfigFile(
|
||||
allNewMigrations.push(...newMigrations)
|
||||
}
|
||||
|
||||
// Record newly applied migrations
|
||||
if (allNewMigrations.length > 0) {
|
||||
const updatedMigrations = Array.from(existingMigrations)
|
||||
updatedMigrations.push(...allNewMigrations)
|
||||
copy._migrations = updatedMigrations
|
||||
// Record newly applied migrations. We persist the full set (existing +
|
||||
// new) to the external sidecar file and strip the legacy `_migrations`
|
||||
// field from the config body on its way out, so users stop having to
|
||||
// think about a field that never should have been in their config in
|
||||
// the first place. The in-memory `rawConfig` never re-exposes
|
||||
// `_migrations` to downstream schema validation.
|
||||
const newMigrationsToRecord = allNewMigrations.filter(mKey => !existingMigrations.has(mKey))
|
||||
if (newMigrationsToRecord.length > 0 || hadLegacyInConfigMigrations) {
|
||||
const fullMigrationSet = new Set<string>([
|
||||
...existingMigrations,
|
||||
...newMigrationsToRecord,
|
||||
])
|
||||
writeAppliedMigrations(configPath, fullMigrationSet)
|
||||
}
|
||||
if (newMigrationsToRecord.length > 0) {
|
||||
needsWrite = true
|
||||
}
|
||||
if (hadLegacyInConfigMigrations) {
|
||||
// Migrating state out of the config body is itself a config write.
|
||||
needsWrite = true
|
||||
}
|
||||
if ("_migrations" in copy) {
|
||||
delete copy._migrations
|
||||
}
|
||||
|
||||
if (copy.omo_agent) {
|
||||
copy.sisyphus_agent = copy.omo_agent
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { getSidecarPath, readAppliedMigrations, writeAppliedMigrations } from "./migrations-sidecar"
|
||||
|
||||
describe("migrations sidecar", () => {
|
||||
let workdir: string
|
||||
|
||||
beforeEach(() => {
|
||||
workdir = mkdtempSync(join(tmpdir(), "omo-migrations-sidecar-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workdir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("getSidecarPath", () => {
|
||||
test("appends .migrations.json to the config path", () => {
|
||||
expect(getSidecarPath("/home/user/.config/opencode/oh-my-openagent.json")).toBe(
|
||||
"/home/user/.config/opencode/oh-my-openagent.json.migrations.json",
|
||||
)
|
||||
})
|
||||
|
||||
test("works for jsonc configs too", () => {
|
||||
expect(getSidecarPath("/home/user/oh-my-openagent.jsonc")).toBe(
|
||||
"/home/user/oh-my-openagent.jsonc.migrations.json",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("readAppliedMigrations", () => {
|
||||
test("returns an empty set when no sidecar exists", () => {
|
||||
const configPath = join(workdir, "oh-my-openagent.json")
|
||||
expect(readAppliedMigrations(configPath).size).toBe(0)
|
||||
})
|
||||
|
||||
test("returns the applied migrations listed in a well-formed sidecar", () => {
|
||||
const configPath = join(workdir, "oh-my-openagent.json")
|
||||
writeFileSync(
|
||||
getSidecarPath(configPath),
|
||||
JSON.stringify({
|
||||
appliedMigrations: [
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const applied = readAppliedMigrations(configPath)
|
||||
|
||||
expect(applied.size).toBe(2)
|
||||
expect(applied.has("model-version:openai/gpt-5.3-codex->openai/gpt-5.4")).toBe(true)
|
||||
expect(applied.has("model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns an empty set on malformed JSON instead of throwing", () => {
|
||||
const configPath = join(workdir, "oh-my-openagent.json")
|
||||
writeFileSync(getSidecarPath(configPath), "{ this is not json")
|
||||
|
||||
expect(readAppliedMigrations(configPath).size).toBe(0)
|
||||
})
|
||||
|
||||
test("returns an empty set when the sidecar payload has the wrong shape", () => {
|
||||
const configPath = join(workdir, "oh-my-openagent.json")
|
||||
writeFileSync(getSidecarPath(configPath), JSON.stringify({ appliedMigrations: "not-an-array" }))
|
||||
|
||||
expect(readAppliedMigrations(configPath).size).toBe(0)
|
||||
})
|
||||
|
||||
test("ignores non-string entries inside appliedMigrations", () => {
|
||||
const configPath = join(workdir, "oh-my-openagent.json")
|
||||
writeFileSync(
|
||||
getSidecarPath(configPath),
|
||||
JSON.stringify({
|
||||
appliedMigrations: ["model-version:a->b", 42, null, "model-version:c->d"],
|
||||
}),
|
||||
)
|
||||
|
||||
const applied = readAppliedMigrations(configPath)
|
||||
|
||||
expect(applied.size).toBe(2)
|
||||
expect(applied.has("model-version:a->b")).toBe(true)
|
||||
expect(applied.has("model-version:c->d")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeAppliedMigrations", () => {
|
||||
test("creates the sidecar with the given migration keys", () => {
|
||||
const configPath = join(workdir, "oh-my-openagent.json")
|
||||
const migrations = new Set([
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
])
|
||||
|
||||
const ok = writeAppliedMigrations(configPath, migrations)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(existsSync(getSidecarPath(configPath))).toBe(true)
|
||||
|
||||
const body = JSON.parse(readFileSync(getSidecarPath(configPath), "utf-8"))
|
||||
expect(body.appliedMigrations).toEqual(["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"])
|
||||
})
|
||||
|
||||
test("writes entries in sorted order for stable diffs", () => {
|
||||
const configPath = join(workdir, "oh-my-openagent.json")
|
||||
const migrations = new Set([
|
||||
"model-version:z->y",
|
||||
"model-version:a->b",
|
||||
"model-version:m->n",
|
||||
])
|
||||
|
||||
writeAppliedMigrations(configPath, migrations)
|
||||
|
||||
const body = JSON.parse(readFileSync(getSidecarPath(configPath), "utf-8"))
|
||||
expect(body.appliedMigrations).toEqual([
|
||||
"model-version:a->b",
|
||||
"model-version:m->n",
|
||||
"model-version:z->y",
|
||||
])
|
||||
})
|
||||
|
||||
test("creates parent directories if they do not exist yet", () => {
|
||||
const nested = join(workdir, "nested", "dir", "that", "does", "not", "exist")
|
||||
const configPath = join(nested, "oh-my-openagent.json")
|
||||
// Parent chain intentionally not created.
|
||||
|
||||
const ok = writeAppliedMigrations(configPath, new Set(["model-version:a->b"]))
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(existsSync(getSidecarPath(configPath))).toBe(true)
|
||||
})
|
||||
|
||||
test("round-trips via readAppliedMigrations", () => {
|
||||
const configPath = join(workdir, "oh-my-openagent.jsonc")
|
||||
const original = new Set([
|
||||
"model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
"model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6",
|
||||
])
|
||||
|
||||
writeAppliedMigrations(configPath, original)
|
||||
const roundTripped = readAppliedMigrations(configPath)
|
||||
|
||||
expect(roundTripped).toEqual(original)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { log } from "../logger"
|
||||
import { writeFileAtomically } from "../write-file-atomically"
|
||||
|
||||
/**
|
||||
* Sidecar state file that tracks applied config migrations outside the user's
|
||||
* config file.
|
||||
*
|
||||
* Why this exists (#3263): users who revert an auto-migrated value (e.g.
|
||||
* `gpt-5.4` → `gpt-5.3-codex`) and then delete the `_migrations` field from
|
||||
* their config would fall into an infinite migration loop — every startup
|
||||
* re-applied the migration because there was no memory of the previous
|
||||
* application. The sidecar remembers applied migrations even when the user
|
||||
* scrubs the config, and only "resets" when the user explicitly deletes both
|
||||
* the config and the sidecar.
|
||||
*
|
||||
* The sidecar lives next to the config file as
|
||||
* `<configFileName>.migrations.json`. One sidecar per config file. The file
|
||||
* format is a flat JSON object:
|
||||
*
|
||||
* {
|
||||
* "appliedMigrations": [
|
||||
* "model-version:openai/gpt-5.3-codex->openai/gpt-5.4",
|
||||
* "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
|
||||
export interface MigrationsSidecar {
|
||||
appliedMigrations: string[]
|
||||
}
|
||||
|
||||
export function getSidecarPath(configPath: string): string {
|
||||
return `${configPath}.migrations.json`
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the set of applied migration keys from the sidecar next to
|
||||
* `configPath`. Returns an empty set on any read or parse failure so the
|
||||
* caller can still trust the return value and safely fall back to the
|
||||
* config's `_migrations` field.
|
||||
*/
|
||||
export function readAppliedMigrations(configPath: string): Set<string> {
|
||||
const sidecarPath = getSidecarPath(configPath)
|
||||
try {
|
||||
if (!fs.existsSync(sidecarPath)) {
|
||||
return new Set()
|
||||
}
|
||||
const content = fs.readFileSync(sidecarPath, "utf-8")
|
||||
const parsed = JSON.parse(content) as unknown
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
Array.isArray((parsed as MigrationsSidecar).appliedMigrations)
|
||||
) {
|
||||
return new Set((parsed as MigrationsSidecar).appliedMigrations.filter((m): m is string => typeof m === "string"))
|
||||
}
|
||||
return new Set()
|
||||
} catch (err) {
|
||||
log(`[migration] Failed to read migrations sidecar at ${sidecarPath}`, err)
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the given set of applied migration keys to the sidecar next to
|
||||
* `configPath`. The sidecar is written atomically. Returns true on success,
|
||||
* false if the write failed (the caller can still proceed — the next
|
||||
* startup will re-run the migration, which is idempotent by design).
|
||||
*/
|
||||
export function writeAppliedMigrations(configPath: string, migrations: Set<string>): boolean {
|
||||
const sidecarPath = getSidecarPath(configPath)
|
||||
const body: MigrationsSidecar = {
|
||||
appliedMigrations: Array.from(migrations).sort(),
|
||||
}
|
||||
try {
|
||||
// Ensure the parent directory exists in case the config file was created
|
||||
// out-of-band. We intentionally do NOT create the sidecar when the migration
|
||||
// set is empty — there is nothing to remember.
|
||||
const parentDir = path.dirname(sidecarPath)
|
||||
if (!fs.existsSync(parentDir)) {
|
||||
fs.mkdirSync(parentDir, { recursive: true })
|
||||
}
|
||||
writeFileAtomically(sidecarPath, JSON.stringify(body, null, 2) + "\n")
|
||||
return true
|
||||
} catch (err) {
|
||||
log(`[migration] Failed to write migrations sidecar at ${sidecarPath}`, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user