Files
oh-my-opencode/src/shared/migration/migrations-sidecar.ts
T
YeonGyu-Kim 4ca4c06698 refactor(migration): auto-upgrade claude-opus-4-5 and 4-6 to claude-opus-4-7
MODEL_VERSION_MAP now chains the legacy claude-opus-4-5 entry straight
to claude-opus-4-7 and adds an explicit claude-opus-4-6 to 4-7 bump
path, letting existing user configs upgrade on next load without an
intermediate 4-6 stop.

MODEL_TO_CATEGORY_MAP picks up claude-opus-4-7 as the canonical
unspecified-high model (prior 4-6 entry is covered by the chained
version map above, so legacy hardcoded configs still resolve).

Migration tests rewritten to reflect the chained 4-5 to 4-7 behavior
and the new 4-6 to 4-7 bump path, including the sidecar-union
scenario.
2026-04-17 14:52:02 +09:00

93 lines
3.3 KiB
TypeScript

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-7"
* ]
* }
*/
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
}
}