Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions packages/cli/src/commands/doctor-compartment-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/commands/doctor-compartment-boundaries.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -19,8 +22,12 @@ export interface DanglingCompartmentBoundary {
export function listDanglingCompartmentBoundaries(
contextDb: Pick<Database, "prepare">,
openCodeDb: Pick<Database, "prepare">,
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");
}
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/commands/doctor-opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Host generation selects stale tables

When the v2 CLI is installed but has not migrated an existing v1 store yet, this override selects session_message instead of the live v1 message table, so doctor can report valid compartment boundaries as dangling. The reverse happens for Desktop installations: their version is "unknown", so a migrated mixed-generation v2 store falls back to schema detection, is classified as v1, and reads the frozen message table. Table selection needs to account for both the host generation and the store's migration state.

);
if (dangling.length === 0) {
pass("Compartment boundary ids resolve in the OpenCode session store");
} else {
Expand Down
75 changes: 75 additions & 0 deletions packages/plugin/src/shared/opencode-db-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
25 changes: 24 additions & 1 deletion packages/plugin/src/shared/opencode-db-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ function schemaTableNames(
): Set<string> {
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] : [])));
Expand All @@ -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<string>): 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,
Expand All @@ -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
Expand Down