From 16da14ce1090cabb9978fd196d484d0addd3a3ec Mon Sep 17 00:00:00 2001 From: qoole <2862661+qoole@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:18:07 +0100 Subject: [PATCH] fix(opencode2): accept v1 stores migrated to OpenCode 2 When OpenCode 2 migrates an existing 1.18.x store, it keeps the v1 `message` and `part` tables beside its own schema. detectOpenCodeStoreGeneration() (rightly, for 1.18.x stores that also ship session_message) calls any store with message+part v1, which breaks OpenCode 2 hosts on every migrated install: - assertOpenCodeStoreGeneration(db, "v2") in V2StoreReader throws "OpenCode store generation mismatch ... expected v2, found v1", the v2 hook refuses, and every turn in every session is interrupted. - doctor's dangling-boundary check reads the `message` table, which is frozen at the migration point, so boundaries written since the upgrade are reported as dangling. `session_v2` is written only by an OpenCode 2 host: the 1.18.31 binary never references it, while 2.0.7 creates it in both fresh and migrated stores. The v2 assertion now also passes when the store has session_message and session_v2. Detection itself is unchanged, so a 1.18.x store is still v1 and still refused for v2 readers, and v1 assertions behave exactly as before. A store can carry both generations in either direction (migrated by 2.x, or used by 1.x again after an upgrade), so table presence cannot say which one is live. The doctor boundary check now takes the host generation doctor already resolved from the active install, and falls back to detection without it, including for Desktop installs, which report no version. (On a real migrated store, 6,529 of its 6,885 v1 message ids are present in session_message under the same ids.) Tests pin the table lists observed on a migrated 2.0.7 store and on a fresh 2.0.7 data directory, and add migrated and downgraded stores to the doctor boundary check. The migrated cases fail without the fix; the downgraded case fails if the table is chosen from session_v2 instead of the host; and dropping the session_v2 requirement fails the existing 1.18.30 fixture. --- .../doctor-compartment-boundaries.test.ts | 35 ++++++++- .../commands/doctor-compartment-boundaries.ts | 11 ++- packages/cli/src/commands/doctor-opencode.ts | 8 +- .../src/shared/opencode-db-path.test.ts | 75 +++++++++++++++++++ .../plugin/src/shared/opencode-db-path.ts | 25 ++++++- 5 files changed, 146 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/doctor-compartment-boundaries.test.ts b/packages/cli/src/commands/doctor-compartment-boundaries.test.ts index 61c13d116b..7667e47dc8 100644 --- a/packages/cli/src/commands/doctor-compartment-boundaries.test.ts +++ b/packages/cli/src/commands/doctor-compartment-boundaries.test.ts @@ -51,16 +51,43 @@ function v2Store(): Database { return db; } +// A 1.18.x store that OpenCode 2 migrated: the v1 `message` table is kept but frozen at the +// migration point (here holding only m1), while session_message holds every id the host serves. +function migratedV2Store(): Database { + const db = v2Store(); + db.exec( + "CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL); CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL); CREATE TABLE session_v2 (id TEXT PRIMARY KEY);", + ); + db.prepare("INSERT INTO message (id, session_id) VALUES ('m1', 'ses-live')").run(); + return db; +} + +// The reverse: OpenCode 2 touched this store (session_v2 exists, session_message frozen at m1), +// then an OpenCode 1.x host kept writing to `message`. +function downgradedV1Store(): Database { + const db = v1Store(); + db.exec( + "CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, type TEXT NOT NULL, seq INTEGER NOT NULL, data TEXT NOT NULL); CREATE TABLE session_v2 (id TEXT PRIMARY KEY);", + ); + db.prepare( + "INSERT INTO session_message (id, session_id, type, seq, data) VALUES ('m1', 'ses-live', 'user', 0, '{}')", + ).run(); + return db; +} + describe("doctor dangling compartment boundary check", () => { - for (const [name, makeStore] of [ - ["v1", v1Store], - ["v2", v2Store], + for (const [name, makeStore, hostGeneration] of [ + ["v1", v1Store, undefined], + ["v2", v2Store, undefined], + // Stores carrying both generations are read by the running host, not by detection. + ["migrated v2", migratedV2Store, "v2"], + ["downgraded v1", downgradedV1Store, "v1"], ] as const) { it(`lists missing start and end ids from the resolved ${name} store`, () => { const context = contextDatabase(); const store = makeStore(); try { - const dangling = listDanglingCompartmentBoundaries(context, store); + const dangling = listDanglingCompartmentBoundaries(context, store, hostGeneration); expect(dangling).toEqual([ { sessionId: "ses-live", diff --git a/packages/cli/src/commands/doctor-compartment-boundaries.ts b/packages/cli/src/commands/doctor-compartment-boundaries.ts index c40731103c..237ccb3489 100644 --- a/packages/cli/src/commands/doctor-compartment-boundaries.ts +++ b/packages/cli/src/commands/doctor-compartment-boundaries.ts @@ -1,4 +1,7 @@ -import { detectOpenCodeStoreGeneration } from "@magic-context/core/shared/opencode-db-path"; +import { + detectOpenCodeStoreGeneration, + type OpenCodeHostGeneration, +} from "@magic-context/core/shared/opencode-db-path"; import type { Database } from "@magic-context/core/shared/sqlite"; interface BoundaryRow { @@ -19,8 +22,12 @@ export interface DanglingCompartmentBoundary { export function listDanglingCompartmentBoundaries( contextDb: Pick, openCodeDb: Pick, + hostGeneration?: OpenCodeHostGeneration, ): DanglingCompartmentBoundary[] { - const generation = detectOpenCodeStoreGeneration(openCodeDb); + // The running host decides which table is live. A store OpenCode 2 migrated keeps its v1 + // tables frozen at the migration point (detection still calls it v1), and a store an + // OpenCode 1.x host uses again after an upgrade keeps a frozen session_message. + const generation = hostGeneration ?? detectOpenCodeStoreGeneration(openCodeDb); if (generation === "unknown") { throw new Error("OpenCode session database has an unrecognized schema"); } diff --git a/packages/cli/src/commands/doctor-opencode.ts b/packages/cli/src/commands/doctor-opencode.ts index 0adedc6d09..5771af2eea 100644 --- a/packages/cli/src/commands/doctor-opencode.ts +++ b/packages/cli/src/commands/doctor-opencode.ts @@ -838,7 +838,13 @@ export async function runDoctor( readonly: true, fileMustExist: true, }); - const dangling = listDanglingCompartmentBoundaries(contextDb, sessionDb); + // Only a parsed version identifies the host; Desktop installs report + // "unknown" (which maps to v1), so leave those to store detection. + const dangling = listDanglingCompartmentBoundaries( + contextDb, + sessionDb, + /\d/.test(activeInstallation.version) ? hostGeneration : undefined, + ); if (dangling.length === 0) { pass("Compartment boundary ids resolve in the OpenCode session store"); } else { diff --git a/packages/plugin/src/shared/opencode-db-path.test.ts b/packages/plugin/src/shared/opencode-db-path.test.ts index 473ba2e138..28658db50e 100644 --- a/packages/plugin/src/shared/opencode-db-path.test.ts +++ b/packages/plugin/src/shared/opencode-db-path.test.ts @@ -253,6 +253,81 @@ describe("resolveOpenCodeDbPath", () => { } }); + it("accepts a v1 store migrated to OpenCode 2 for v2 readers, and still for v1 readers", () => { + const { openCodeDir } = useDataHome(); + const migratedPath = join(openCodeDir, "migrated-v2.db"); + const migrated = new Database(migratedPath); + try { + // Captured from an OpenCode 2.0.7 store that the v2 host migrated from a 1.18.x + // store: it keeps the v1 `message`/`part` tables beside its own schema, so it + // carries BOTH generations. `session_v2` is written only by an OpenCode 2 host + // (the 1.18.31 binary never references it). Keep this table list as observed. + for (const table of [ + "account", + "account_state", + "control_account", + "credential", + "event", + "event_sequence", + "instruction_blob", + "instruction_entry", + "instruction_state", + "kv", + "message", + "migration", + "part", + "permission", + "project", + "project_directory", + "session", + "session_inbox", + "session_message", + "session_pending", + "session_share", + "session_v2", + "todo", + "workspace", + "worktree", + ]) { + migrated.exec(`CREATE TABLE ${table}(id TEXT)`); + } + expect(() => assertOpenCodeStoreGeneration(migrated, "v2", migratedPath)).not.toThrow(); + expect(() => assertOpenCodeStoreGeneration(migrated, "v1", migratedPath)).not.toThrow(); + } finally { + migrated.close(); + } + }); + + it("reads a fresh OpenCode 2 store as v2 only", () => { + const { openCodeDir } = useDataHome(); + const freshV2Path = join(openCodeDir, "fresh-v2.db"); + const freshV2 = new Database(freshV2Path); + try { + // Captured from a new OpenCode 2.0.7 data directory: no v1 message tables. + for (const table of [ + "account", + "event", + "migration", + "permission", + "project", + "session_inbox", + "session_message", + "session_pending", + "session_v2", + "workspace", + ]) { + freshV2.exec(`CREATE TABLE ${table}(id TEXT)`); + } + expect(detectOpenCodeStoreGeneration(freshV2)).toBe("v2"); + expect(() => assertOpenCodeStoreGeneration(freshV2, "v2", freshV2Path)).not.toThrow(); + expect(() => assertOpenCodeStoreGeneration(freshV2, "v1", freshV2Path)).toThrow( + "expected v1, found v2", + ); + } finally { + freshV2.close(); + } + }); + it("treats a store with no schema yet as empty rather than as a conflicting host", () => { const { openCodeDir } = useDataHome(); const freshPath = join(openCodeDir, "fresh.db"); diff --git a/packages/plugin/src/shared/opencode-db-path.ts b/packages/plugin/src/shared/opencode-db-path.ts index 7cc5a323b2..df2679f49a 100644 --- a/packages/plugin/src/shared/opencode-db-path.ts +++ b/packages/plugin/src/shared/opencode-db-path.ts @@ -214,7 +214,7 @@ function schemaTableNames( ): Set { const rows = db .prepare( - `SELECT name FROM ${schema}.sqlite_master WHERE type = 'table' AND name IN ('message', 'part', 'session', 'project', 'session_message')`, + `SELECT name FROM ${schema}.sqlite_master WHERE type = 'table' AND name IN ('message', 'part', 'session', 'project', 'session_message', 'session_v2')`, ) .all() as Array<{ name?: unknown }>; return new Set(rows.flatMap((row) => (typeof row.name === "string" ? [row.name] : []))); @@ -240,6 +240,28 @@ export function detectOpenCodeStoreGeneration( return "unknown"; } +/** + * A store the OpenCode 2 host migrated from a v1 store keeps the v1 `message`/`part` tables + * beside its own schema, so it carries BOTH generations and `detectOpenCodeStoreGeneration` + * (which must keep calling a 1.18.x store v1) reports v1. `session_v2` is written only by an + * OpenCode 2 host (1.18.x never creates it), so a store with it and `session_message` is + * readable by v2 readers regardless of any v1 tables it also kept. + */ +function hasMigratedV2Schema(tables: Set): boolean { + return tables.has("session_message") && tables.has("session_v2"); +} + +/** A native OpenCode 2 store, or a v1 store an OpenCode 2 host has migrated. */ +function isOpenCodeV2Store( + db: OpenCodeStoreSchemaDatabase, + schema: "main" | "oc_backfill" = "main", +): boolean { + return ( + detectOpenCodeStoreGeneration(db, schema) === "v2" || + hasMigratedV2Schema(schemaTableNames(db, schema)) + ); +} + /** Refuse before a generation-specific query can read the other host's schema. */ export function assertOpenCodeStoreGeneration( db: OpenCodeStoreSchemaDatabase, @@ -249,6 +271,7 @@ export function assertOpenCodeStoreGeneration( ): void { const actual = detectOpenCodeStoreGeneration(db, schema); if (actual === expected) return; + if (expected === "v2" && isOpenCodeV2Store(db, schema)) return; // A store with none of these tables has no schema YET — a host that has not written its // first row, or a fresh data directory. That is "nothing to read", not a conflicting host, // and readers have always treated it as empty. Refusing here made every reader throw before