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