diff --git a/apps/desktop/electron/main/importers/opencode-sqlite.ts b/apps/desktop/electron/main/importers/opencode-sqlite.ts new file mode 100644 index 0000000000..36624a98bb --- /dev/null +++ b/apps/desktop/electron/main/importers/opencode-sqlite.ts @@ -0,0 +1,131 @@ +import { existsSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import type { OpenCodeMessage, OpenCodePart } from "./opencode"; +import type { ExternalSessionSummary } from "./types"; +import { toIso, truncateTitle } from "./types"; + +// OpenCode v1.18.26, commit 774cc7c: core/src/session/sql.ts and +// opencode/src/session/message-v2.ts. These columns also cover the initial +// SQLite migration. The separate session_message projection belongs to v2. +const PAGE_SIZE = 256; +type Row = Record; +type MessageWithParts = { info: OpenCodeMessage; parts: OpenCodePart[] }; + +function text(value: unknown): string { + if (typeof value !== "string") throw new Error("Invalid OpenCode text field"); + return value; +} + +function timestamp(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("Invalid OpenCode timestamp"); + return value; +} + +function object(value: unknown): Row { + let parsed: unknown; + try { parsed = JSON.parse(text(value)); } + catch { throw new Error("Invalid OpenCode JSON data"); } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid OpenCode data"); + return parsed as Row; +} + +function readSnapshot(file: string, read: (db: DatabaseSync) => T): T { + const db = new DatabaseSync(file, { readOnly: true, allowExtension: false }); + try { + // Bound contention with an active OpenCode writer; never migrate, recover, + // checkpoint, or copy the external database (a live WAL may hold its tail). + db.exec("PRAGMA busy_timeout = 250; PRAGMA trusted_schema = OFF; BEGIN"); + return read(db); + } finally { + db.close(); + } +} + +export async function scanOpenCodeDatabase(file: string): Promise { + if (!existsSync(file)) return []; + try { + return readSnapshot(file, (db) => { + // Reject incomplete/foreign schemas before giving their rows priority + // over surviving JSON copies. + db.prepare("SELECT id, message_id, session_id, data FROM part LIMIT 0"); + const page = db.prepare(`SELECT s.id, s.title, s.directory, s.time_created, s.time_updated, + (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) AS message_count + FROM session s WHERE s.id > ? ORDER BY s.id LIMIT ?`); + const summaries: ExternalSessionSummary[] = []; + let cursor = ""; + for (;;) { + const rows = page.all(cursor, PAGE_SIZE); + for (const row of rows) { + const id = text(row.id); + if (!/^[A-Za-z0-9_-]+$/.test(id) || Number(row.message_count) === 0) continue; + summaries.push({ + source: "opencode", externalId: id, + title: truncateTitle(text(row.title)) || id, + projectPath: text(row.directory) || null, + model: null, createdAt: toIso(timestamp(row.time_created)), + updatedAt: toIso(timestamp(row.time_updated)), + messageCount: Number(row.message_count), filePath: file, + }); + } + if (rows.length < PAGE_SIZE) break; + cursor = text(rows[rows.length - 1].id); + } + return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.externalId.localeCompare(b.externalId)); + }); + } catch { + // A broken/locked/newer database must not hide legacy JSON candidates. + // Never log SQL data, session content, or source paths. + console.warn("[session.import] OpenCode SQLite scan failed; legacy JSON scan remains available"); + return []; + } +} + +export function readOpenCodeDatabase(file: string, sessionId: string): MessageWithParts[] { + return readSnapshot(file, (db) => { + if (!db.prepare("SELECT id FROM session WHERE id = ?").get(sessionId)) { + throw new Error("OpenCode session no longer exists"); + } + const messages = db.prepare(`SELECT id, time_created, data FROM message + WHERE session_id = ? AND (time_created > ? OR (time_created = ? AND id > ?)) + ORDER BY time_created, id LIMIT ?`); + const parts = db.prepare(`SELECT id, data FROM part + WHERE session_id = ? AND message_id = ? ORDER BY id`); + const result: MessageWithParts[] = []; + let time = Number.MIN_SAFE_INTEGER; + let id = ""; + for (;;) { + const rows = messages.all(sessionId, time, time, id, PAGE_SIZE); + for (const row of rows) { + const data = object(row.data); + if (data.role !== "user" && data.role !== "assistant") throw new Error("Invalid OpenCode message role"); + const messageId = text(row.id); + const hydrated = parts.all(sessionId, messageId).map((part) => { + const value = object(part.data); + if (typeof value.type !== "string") throw new Error("Invalid OpenCode part type"); + if (value.type === "text" && typeof value.text !== "string") throw new Error("Invalid OpenCode text part"); + if (value.type === "tool" && (!value.state || typeof value.state !== "object" || Array.isArray(value.state))) { + throw new Error("Invalid OpenCode tool part"); + } + if (value.type === "tool" && (typeof value.tool !== "string" || typeof value.callID !== "string")) { + throw new Error("Invalid OpenCode tool identity"); + } + return { ...value, id: text(part.id) } as OpenCodePart; + }); + result.push({ + info: { + id: messageId, sessionID: sessionId, role: data.role, + modelID: typeof data.modelID === "string" ? data.modelID : undefined, + providerID: typeof data.providerID === "string" ? data.providerID : undefined, + time: { created: timestamp(row.time_created) }, + }, + parts: hydrated, + }); + } + if (rows.length < PAGE_SIZE) break; + const last = rows[rows.length - 1]; + time = timestamp(last.time_created); + id = text(last.id); + } + return result; + }); +} diff --git a/apps/desktop/electron/main/importers/opencode.ts b/apps/desktop/electron/main/importers/opencode.ts index 91a8146787..aa59732f1c 100644 --- a/apps/desktop/electron/main/importers/opencode.ts +++ b/apps/desktop/electron/main/importers/opencode.ts @@ -8,8 +8,17 @@ import type { SessionImporter, } from "./types"; import { importedSessionId, toIso, truncateTitle } from "./types"; +import { readOpenCodeDatabase, scanOpenCodeDatabase } from "./opencode-sqlite"; -const STORAGE_DIR = path.join(os.homedir(), ".local", "share", "opencode", "storage"); +export function openCodeDataDir(home = os.homedir(), env = process.env): string { + const xdg = env.XDG_DATA_HOME; + return path.join(xdg && path.isAbsolute(xdg) ? xdg : path.join(home, ".local", "share"), "opencode"); +} + +/** IDs become paths only for the legacy JSON store. Reject both OS separators. */ +export function isOpenCodeId(value: unknown): value is string { + return typeof value === "string" && /^[A-Za-z0-9_-]+$/.test(value); +} interface OpenCodeSession { id: string; @@ -19,7 +28,7 @@ interface OpenCodeSession { time?: { created?: number; updated?: number }; } -interface OpenCodeMessage { +export interface OpenCodeMessage { id: string; sessionID: string; role?: string; @@ -28,14 +37,14 @@ interface OpenCodeMessage { time?: { created?: number; completed?: number }; } -interface OpenCodePart { +export interface OpenCodePart { id: string; type?: string; text?: string; synthetic?: boolean; tool?: string; callID?: string; - state?: { input?: unknown; output?: unknown; status?: string }; + state?: { input?: unknown; output?: unknown; error?: string; status?: string }; } async function readJson(filePath: string): Promise { @@ -54,130 +63,148 @@ async function listJsonFiles(dir: string): Promise { } } -async function loadMessages(sessionId: string): Promise { - const dir = path.join(STORAGE_DIR, "message", sessionId); +async function loadMessages(storageDir: string, sessionId: string): Promise { + const dir = path.join(storageDir, "message", sessionId); const out: OpenCodeMessage[] = []; for (const file of await listJsonFiles(dir)) { const msg = await readJson(path.join(dir, file)); - if (msg) out.push(msg); + if (msg && isOpenCodeId(msg.id) && (!msg.sessionID || msg.sessionID === sessionId)) out.push(msg); } out.sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0)); return out; } -export const opencodeImporter: SessionImporter = { - source: "opencode", +export function createOpenCodeImporter(dataDir = openCodeDataDir()): SessionImporter { + const storageDir = path.join(dataDir, "storage"); + const databasePath = path.join(dataDir, "opencode.db"); + return { + source: "opencode", - async scan(): Promise { - const sessionRoot = path.join(STORAGE_DIR, "session"); - let projectDirs: string[] = []; - try { - projectDirs = await fs.readdir(sessionRoot); - } catch { - return []; - } - const summaries: ExternalSessionSummary[] = []; - for (const dir of projectDirs) { - const dirPath = path.join(sessionRoot, dir); - let stat; + async scan(): Promise { + const databaseSessions = await scanOpenCodeDatabase(databasePath); + const sessionRoot = path.join(storageDir, "session"); + let projectDirs: string[] = []; try { - stat = await fs.stat(dirPath); + projectDirs = await fs.readdir(sessionRoot); } catch { - continue; + return databaseSessions; } - if (!stat.isDirectory()) continue; - for (const file of await listJsonFiles(dirPath)) { - const session = await readJson(path.join(dirPath, file)); - if (!session?.id) continue; - let messageCount = 0; + const summaries: ExternalSessionSummary[] = []; + for (const dir of projectDirs) { + const dirPath = path.join(sessionRoot, dir); + let stat; try { - messageCount = ( - await fs.readdir(path.join(STORAGE_DIR, "message", session.id)) - ).filter((f) => f.endsWith(".json")).length; + stat = await fs.stat(dirPath); } catch { continue; } - if (messageCount === 0) continue; - summaries.push({ - source: "opencode", - externalId: session.id, - title: truncateTitle(session.title ?? "") || session.id, - projectPath: session.directory ?? null, - model: null, - createdAt: toIso(session.time?.created), - updatedAt: toIso(session.time?.updated, toIso(session.time?.created)), - messageCount, - filePath: path.join(dirPath, file), - }); + if (!stat.isDirectory()) continue; + for (const file of await listJsonFiles(dirPath)) { + const session = await readJson(path.join(dirPath, file)); + if (!session || !isOpenCodeId(session.id)) continue; + let messageCount = 0; + try { + messageCount = ( + await fs.readdir(path.join(storageDir, "message", session.id)) + ).filter((f) => f.endsWith(".json")).length; + } catch { + continue; + } + if (messageCount === 0) continue; + summaries.push({ + source: "opencode", + externalId: session.id, + title: truncateTitle(session.title ?? "") || session.id, + projectPath: session.directory ?? null, + model: null, + createdAt: toIso(session.time?.created), + updatedAt: toIso(session.time?.updated, toIso(session.time?.created)), + messageCount, + filePath: path.join(dirPath, file), + }); + } } - } - return summaries; - }, + // Migrated JSON files can remain on disk. The current DB copy wins. + const merged = new Map(summaries.map((item) => [item.externalId, item])); + for (const item of databaseSessions) merged.set(item.externalId, item); + return [...merged.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.externalId.localeCompare(b.externalId)); + }, - async convert(summary: ExternalSessionSummary): Promise { - const ocMessages = await loadMessages(summary.externalId); - const messages: ImportedUiMessage[] = []; - let modelId: string | null = null; - let providerId: string | null = null; + async convert(summary: ExternalSessionSummary): Promise { + if (!isOpenCodeId(summary.externalId)) throw new Error("Invalid OpenCode session ID"); + const databaseMessages = summary.filePath === databasePath + ? readOpenCodeDatabase(databasePath, summary.externalId) + : undefined; + const ocMessages: Array<{ info: OpenCodeMessage; parts?: OpenCodePart[] }> = databaseMessages ?? (await loadMessages(storageDir, summary.externalId)).map((info) => ({ info })); + const messages: ImportedUiMessage[] = []; + let modelId: string | null = null; + let providerId: string | null = null; - for (const msg of ocMessages) { - if (msg.role === "assistant") { - modelId = msg.modelID ?? modelId; - providerId = msg.providerID ?? providerId; - } - const createdAt = toIso(msg.time?.created); - const partDir = path.join(STORAGE_DIR, "part", msg.id); - const texts: string[] = []; - const toolMessages: ImportedUiMessage[] = []; - for (const file of await listJsonFiles(partDir)) { - const part = await readJson(path.join(partDir, file)); - if (!part) continue; - if (part.type === "text" && part.text && part.synthetic !== true) { - texts.push(part.text); - } else if (part.type === "tool") { - const output = part.state?.output; - const outputText = - typeof output === "string" ? output : output ? JSON.stringify(output) : ""; - toolMessages.push({ + for (const entry of ocMessages) { + const msg = entry.info; + if (msg.role !== "user" && msg.role !== "assistant") continue; + if (msg.role === "assistant") { + modelId = msg.modelID ?? modelId; + providerId = msg.providerID ?? providerId; + } + const createdAt = toIso(msg.time?.created); + const partDir = path.join(storageDir, "part", msg.id); + const texts: string[] = []; + const toolMessages: ImportedUiMessage[] = []; + const parts = entry.parts ?? await Promise.all( + (await listJsonFiles(partDir)).map((file) => readJson(path.join(partDir, file))), + ); + for (const part of parts) { + if (!part) continue; + if (part.type === "text" && part.text && part.synthetic !== true) { + texts.push(part.text); + } else if (part.type === "tool") { + const output = part.state?.status === "error" ? part.state.error ?? part.state.output : part.state?.output; + const outputText = + typeof output === "string" ? output : output ? JSON.stringify(output) : ""; + toolMessages.push({ + id: crypto.randomUUID(), + role: "tool", + content: outputText, + createdAt, + toolName: part.tool, + toolCallId: part.callID, + toolStatus: part.state?.status === "error" ? "error" : "success", + toolArgs: part.state?.input, + toolResult: outputText, + isError: part.state?.status === "error" || undefined, + status: "complete", + }); + } + } + const text = texts.join("\n").trim(); + if (text) { + messages.push({ id: crypto.randomUUID(), - role: "tool", - content: outputText, + role: msg.role === "user" ? "user" : "assistant", + content: text, createdAt, - toolName: part.tool, - toolCallId: part.callID, - toolStatus: part.state?.status === "error" ? "error" : "success", - toolArgs: part.state?.input, - toolResult: outputText, - isError: part.state?.status === "error" || undefined, - status: "complete", + status: msg.role === "assistant" ? "complete" : undefined, }); } + messages.push(...toolMessages); } - const text = texts.join("\n").trim(); - if (text) { - messages.push({ - id: crypto.randomUUID(), - role: msg.role === "user" ? "user" : "assistant", - content: text, - createdAt, - status: msg.role === "assistant" ? "complete" : undefined, - }); - } - messages.push(...toolMessages); - } - return { - session: { - id: importedSessionId("opencode", summary.externalId), - title: summary.title, - projectPath: summary.projectPath, - modelId, - providerId, - mode: "agent", - createdAt: summary.createdAt, - updatedAt: summary.updatedAt, - }, - messages, - }; - }, -}; + return { + session: { + id: importedSessionId("opencode", summary.externalId), + title: summary.title, + projectPath: summary.projectPath, + modelId, + providerId, + mode: "agent", + createdAt: summary.createdAt, + updatedAt: summary.updatedAt, + }, + messages, + }; + }, + }; +} + +export const opencodeImporter = createOpenCodeImporter(); diff --git a/apps/desktop/test/importer-opencode.test.mjs b/apps/desktop/test/importer-opencode.test.mjs new file mode 100644 index 0000000000..2f8219d9e9 --- /dev/null +++ b/apps/desktop/test/importer-opencode.test.mjs @@ -0,0 +1,234 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { register } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; + +register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); + +// Use an isolated source root; never open the developer's OpenCode history. +const home = await mkdtemp(join(tmpdir(), "pi-opencode-import-")); +const { createOpenCodeImporter, openCodeDataDir } = await import("../electron/main/importers/opencode.ts"); + +// OpenCode v1.18.26 (774cc7c): packages/core/src/session/sql.ts and +// packages/opencode/src/session/message-v2.ts. Message/part JSON omits the +// relational IDs; message order is (time_created, id), part order is id. +function database(file) { + const db = new DatabaseSync(file); + db.exec(` + CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, + slug TEXT, directory TEXT, title TEXT, version TEXT, + time_created INTEGER, time_updated INTEGER); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, + time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, + time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL); + CREATE INDEX message_session_time_created_id_idx ON message(session_id,time_created,id); + CREATE INDEX part_message_id_id_idx ON part(message_id,id); + `); + return db; +} + +test("production scan and convert detect a DB-only OpenCode v1 session", async () => { + try { + const dir = join(home, ".local", "share", "opencode"); + await mkdir(dir, { recursive: true }); + const db = database(join(dir, "opencode.db")); + db.prepare("INSERT INTO session VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run("ses_fixture", "project", null, "fixture", "/fixture/project", "SQLite session", "1.18.26", 1000, 2000); + db.prepare("INSERT INTO message VALUES (?, ?, ?, ?, ?)") + .run("msg_fixture", "ses_fixture", 1100, 1100, JSON.stringify({ role: "user", time: { created: 1100 } })); + db.prepare("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)") + .run("prt_fixture", "msg_fixture", "ses_fixture", 1100, 1100, JSON.stringify({ type: "text", text: "Hello from SQLite" })); + db.close(); + const importer = createOpenCodeImporter(dir); + const sessions = await importer.scan(); + assert.equal(sessions.length, 1, "DB-only sessions must be discoverable"); + const imported = await importer.convert(sessions[0]); + assert.equal(imported.session.id, "import-opencode-ses_fixture"); + assert.equal(imported.messages[0].content, "Hello from SQLite"); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +async function fixture(run) { + const dir = await mkdtemp(join(tmpdir(), "pi-opencode-fixture-")); + try { await run(dir, createOpenCodeImporter(dir)); } + finally { await rm(dir, { recursive: true, force: true }); } +} + +function session(db, id, updated = 2000) { + db.prepare("INSERT INTO session VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run(id, "project", null, "fixture", "C:/fixture/project", `DB ${id}`, "1.18.26", 1000, updated); +} + +function message(db, sessionId, id, created, role, parts, extra = {}) { + db.prepare("INSERT INTO message VALUES (?, ?, ?, ?, ?)") + .run(id, sessionId, created, created, JSON.stringify({ role, time: { created }, ...extra })); + const statement = db.prepare("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)"); + for (const [partId, data] of parts) statement.run(partId, id, sessionId, created, created, JSON.stringify(data)); +} + +async function legacy(dir, id, title = "Legacy session") { + for (const subdir of ["session/project", `message/${id}`, `part/msg_${id}`]) { + await mkdir(join(dir, "storage", subdir), { recursive: true }); + } + await writeFile(join(dir, "storage", "session/project", `${id}.json`), JSON.stringify({ id, title, directory: "/legacy", time: { created: 100, updated: 500 } })); + await writeFile(join(dir, "storage/message", id, "message.json"), JSON.stringify({ id: `msg_${id}`, sessionID: id, role: "user", time: { created: 200 } })); + await writeFile(join(dir, "storage/part", `msg_${id}`, "part.json"), JSON.stringify({ id: "prt_legacy", type: "text", text: "Legacy text" })); +} + +test("resolves the default and absolute XDG data roots without a cwd-relative override", () => { + assert.equal(openCodeDataDir("/fixture", {}), join("/fixture", ".local/share/opencode")); + assert.equal(openCodeDataDir("/fixture", { XDG_DATA_HOME: "/xdg" }), join("/xdg", "opencode")); + assert.equal(openCodeDataDir("/fixture", { XDG_DATA_HOME: "relative" }), join("/fixture", ".local/share/opencode")); +}); + +test("production factory discovers the store under XDG_DATA_HOME", async () => { + await fixture(async (dir) => { + const data = join(dir, "opencode"); + await mkdir(data); + await legacy(data, "ses_xdg"); + const previous = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = dir; + try { + const importer = createOpenCodeImporter(); + const [found] = await importer.scan(); + assert.equal(found.externalId, "ses_xdg"); + assert.equal((await importer.convert(found)).messages[0].content, "Legacy text"); + } finally { + if (previous === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previous; + } + }); +}); + +test("SQLite and JSON coexist, deduplicate by session ID and retain legacy conversion", async () => { + await fixture(async (dir, importer) => { + await legacy(dir, "ses_shared", "Old copy"); + await legacy(dir, "ses_legacy"); + const file = join(dir, "opencode.db"); + const db = database(file); + session(db, "ses_shared"); + message(db, "ses_shared", "msg_shared", 1200, "user", [["part_shared", { type: "text", text: "Current DB text" }]]); + session(db, "ses_empty"); + db.close(); + const before = await readFile(file); + const found = await importer.scan(); + assert.deepEqual(found.map((s) => s.externalId), ["ses_shared", "ses_legacy"]); + assert.equal(found[0].title, "DB ses_shared"); + assert.equal(found[0].projectPath, "C:/fixture/project"); + assert.equal((await importer.convert(found[0])).messages[0].content, "Current DB text"); + assert.equal((await importer.convert(found[1])).messages[0].content, "Legacy text"); + assert.deepEqual(await readFile(file), before, "source DB bytes must not change"); + }); +}); + +test("reads every page with tied timestamps and ID-ordered text/tool parts", async () => { + await fixture(async (dir, importer) => { + const db = database(join(dir, "opencode.db")); + session(db, "ses_history"); + // >2 pages; reverse insertion and a shared timestamp rule out rowid ordering + // or a time-only cursor that would lose the second page. + for (let index = 519; index >= 0; index--) { + const id = `msg_${String(index).padStart(4, "0")}`; + message(db, "ses_history", id, 1200, "user", [[`prt_${id}`, { type: "text", text: String(index) }]]); + } + message(db, "ses_history", "msg_final", 1300, "assistant", [ + ["prt_z", { type: "tool", tool: "Read", callID: "call_error", state: { status: "error", input: { path: "b" }, error: "Fixture failure" } }], + ["prt_c", { type: "text", text: "second" }], + ["prt_b", { type: "text", text: "synthetic", synthetic: true }], + ["prt_a", { type: "text", text: "first" }], + ["prt_y", { type: "tool", tool: "Read", callID: "call_ok", state: { status: "completed", input: { path: "a" }, output: "Read result" } }], + ["prt_d", { type: "reasoning", text: "private reasoning" }], + ], { modelID: "fixture-model", providerID: "fixture-provider" }); + // Another session's part must not leak even if its message_id is forged. + db.prepare("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)").run("prt_foreign", "msg_final", "ses_foreign", 1300, 1300, JSON.stringify({ type: "text", text: "foreign" })); + db.close(); + const [found] = await importer.scan(); + assert.equal(found.messageCount, 521); + const result = await importer.convert(found); + assert.equal(result.messages.length, 523); + assert.deepEqual(result.messages.slice(0, 520).map((m) => m.content), Array.from({ length: 520 }, (_, n) => String(n))); + assert.equal(result.messages[520].role, "assistant"); + assert.equal(result.messages[520].content, "first\nsecond"); + assert.deepEqual(result.messages.slice(521).map((m) => [m.toolCallId, m.toolStatus, m.content]), [["call_ok", "success", "Read result"], ["call_error", "error", "Fixture failure"]]); + assert.deepEqual(result.messages[521].toolArgs, { path: "a" }); + assert.equal(result.messages[522].isError, true); + assert.equal(result.session.modelId, "fixture-model"); + assert.equal(result.session.providerId, "fixture-provider"); + }); +}); + +test("session scanning spans all pages rather than silently truncating an archive", async () => { + await fixture(async (dir, importer) => { + const db = database(join(dir, "opencode.db")); + for (let n = 0; n < 260; n++) { + const id = `ses_${String(n).padStart(4, "0")}`; + session(db, id); + message(db, id, `msg_${id}`, 1200, "user", []); + } + db.close(); + assert.equal((await importer.scan()).length, 260); + }); +}); + +for (const problem of ["missing", "corrupt", "unsupported", "missing-parts", "locked"]) { + test(`${problem} SQLite does not block old JSON discovery`, async () => { + await fixture(async (dir, importer) => { + await legacy(dir, "ses_legacy"); + const file = join(dir, "opencode.db"); + let db; + if (problem === "corrupt") await writeFile(file, "not a SQLite database"); + if (problem === "unsupported") { db = new DatabaseSync(file); db.exec("CREATE TABLE unrelated (value TEXT)"); } + if (problem === "missing-parts") { db = database(file); session(db, "ses_legacy"); db.exec("DROP TABLE part"); } + if (problem === "locked") { db = database(file); db.exec("BEGIN EXCLUSIVE"); } + try { + const found = await importer.scan(); + assert.deepEqual(found.map((s) => s.externalId), ["ses_legacy"]); + assert.equal((await importer.convert(found[0])).messages[0].content, "Legacy text"); + } finally { db?.close(); } + }); + }); +} + +test("reads committed WAL data without checkpointing or importing an uncommitted write", async () => { + await fixture(async (dir, importer) => { + const file = join(dir, "opencode.db"); + const db = database(file); + try { + db.exec("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0"); + session(db, "ses_wal"); + message(db, "ses_wal", "msg_wal", 1200, "user", [["prt_wal", { type: "text", text: "Committed WAL text" }]]); + const before = await readFile(file); + const wal = await readFile(`${file}-wal`); + db.exec("BEGIN IMMEDIATE"); + session(db, "ses_pending"); + const found = await importer.scan(); + assert.deepEqual(found.map((s) => s.externalId), ["ses_wal"]); + assert.equal((await importer.convert(found[0])).messages[0].content, "Committed WAL text"); + assert.deepEqual(await readFile(file), before); + assert.deepEqual(await readFile(`${file}-wal`), wal); + } finally { db.close(); } + }); +}); + +test("a changed or malformed selected DB fails conversion without importing a partial transcript", async () => { + await fixture(async (dir, importer) => { + const file = join(dir, "opencode.db"); + const db = database(file); + session(db, "ses_corrupt"); + message(db, "ses_corrupt", "msg_good", 1200, "user", [["prt_good", { type: "text", text: "good" }]]); + const [found] = await importer.scan(); + db.prepare("INSERT INTO message VALUES (?, ?, ?, ?, ?)").run("msg_bad", "ses_corrupt", 1300, 1300, "not json"); + await assert.rejects(importer.convert(found)); + db.exec("DELETE FROM session"); + await assert.rejects(importer.convert(found), /no longer exists/); + db.close(); + await assert.rejects(importer.convert({ ...found, externalId: "../outside" }), /Invalid OpenCode session ID/); + await assert.rejects(importer.convert({ ...found, externalId: "..\\outside" }), /Invalid OpenCode session ID/); + }); +}); diff --git a/docs/spec/04-ux/06-settings-ia.md b/docs/spec/04-ux/06-settings-ia.md index bf19d2b681..78ee4ab670 100644 --- a/docs/spec/04-ux/06-settings-ia.md +++ b/docs/spec/04-ux/06-settings-ia.md @@ -579,6 +579,25 @@ system while preserving their different data ownership: labels. The skills kind carries the import mode (copy or symlink); the MCP kind writes into the same MCP list the MCP destination manages. +- OpenCode session discovery reads `opencode.db` below the absolute + `XDG_DATA_HOME/opencode` root, or `~/.local/share/opencode` when XDG is absent + or relative. It also retains the legacy `storage/session`, `message`, and + `part` JSON layout. A session present in both stores appears once, using the + database copy. Empty sessions are not offered. +- SQLite is an external, read-only source: no migrations, writes, recovery or + checkpoints. Read transactions include committed WAL content and bound lock + waits to 250 ms. Missing, locked, damaged or incompatible databases do not + prevent the legacy JSON scan. Conversion errors fail that selected session + before any host import; they never silently persist a partial transcript. +- The supported SQLite schema is OpenCode v1's `session`, `message` and `part` + tables, verified against v1.18.26 (`774cc7c`). Sessions and messages are read + in complete keyset pages; messages follow `(time_created, id)` and parts + follow `id`. Source IDs, timestamps, project directory and assistant model + metadata retain the existing import semantics. Visible, non-synthetic text + and tool input/output/error parts use the existing converter; reasoning, + attachments and structural parts remain outside its content scope. This + does not import the separate v2 `session_message` projection. + ### Project archive - Reuses the durable Projects index as a settings-scale management surface - Always includes archived records; archived rows are grouped, never hidden, so diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 29d07c29ea..3e0adab869 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -13854,3 +13854,28 @@ the latest destination. These assertions measure work counts, not device FPS. - **Automation:** `pnpm test:e2e:dialog-overflow`; source/contract dialog suites supplement, but do not replace, real Chromium geometry and pointer checks. - **Status:** Implemented. Native Windows evidence; macOS/Linux not qualified. + +### E2E-IMPORT-opencode-sqlite + +- **Source contract:** OpenCode v1.18.26, commit + `774cc7c1914e4329eefde5a669f938b0cf566661`: + [schema](https://github.com/anomalyco/opencode/blob/774cc7c1914e4329eefde5a669f938b0cf566661/packages/core/src/session/sql.ts) + and [v1 reader](https://github.com/anomalyco/opencode/blob/774cc7c1914e4329eefde5a669f938b0cf566661/packages/opencode/src/session/message-v2.ts). +- **Preconditions:** Isolated temporary source root and real SQLite fixtures; + no developer/user agent databases or provider credentials. +- **Steps:** Scan a DB-only store, select and convert its session. Repeat with + absolute XDG discovery, surviving JSON copies, over 256 sessions, over 512 + messages with tied timestamps, reverse-inserted parts, assistant model + metadata and successful/failed tools. Scan while a WAL writer has committed + and uncommitted data. Repeat with missing, locked, corrupt or incomplete + databases; delete/corrupt a selected session before conversion. +- **Expected:** DB-only sessions are discovered; current database copies win + duplicate IDs, JSON-only sessions remain importable, pages lose no rows, + message/part traversal follows the v1 contract, and source DB/WAL bytes do not + change. Cross-session parts never leak. Conversion of malformed/removed + records fails without a partial import; legacy scans survive SQLite errors. + IDs containing path separators are rejected. Reasoning/attachments and the + v2 `session_message` projection retain the documented unsupported boundary. +- **Automation:** `node --test apps/desktop/test/importer-opencode.test.mjs` + exercises the production importer service with real filesystem/SQLite + boundaries. It does not claim native Windows or the full Electron UI flow. diff --git a/docs/zh-CN/spec/04-ux/06-settings-ia.md b/docs/zh-CN/spec/04-ux/06-settings-ia.md index 62e37dc713..b2ad3c380c 100644 --- a/docs/zh-CN/spec/04-ux/06-settings-ia.md +++ b/docs/zh-CN/spec/04-ux/06-settings-ia.md @@ -243,6 +243,22 @@ Token 用量**不是设置目的地**(D335 / ADR 0173)。已完成回合历 这些配置里保存的 API 密钥会复制到宿主密钥库;订阅/OAuth 登录不复制。CC Switch(`~/.cc-switch`)作为独立来源扫描,因此可导入已保存的配置档案,而不只是当前生效的 live 文件。再次导入等价提供商(规范化 base URL、API 风格和相同凭据)时跳过;同一端点不同凭据的配置档案保持独立。若应用尚无默认模型,则本次新建的第一个提供商成为默认。 - 技能和 MCP 服务器复用智能体能力扫描器及其来源标签。技能类型携带导入模式(复制或符号链接);MCP 类型写入 MCP 目的地所管理的同一个 MCP 列表。 +- OpenCode 会话发现会读取绝对路径 `XDG_DATA_HOME/opencode` 下的 + `opencode.db`;XDG 未设置或为相对路径时,则读取 + `~/.local/share/opencode`。同时继续支持旧版 `storage/session`、`message` + 和 `part` JSON 布局。同一会话同时存在于两种存储时只显示一次,并以数据库 + 副本为准;空会话不提供导入。 +- SQLite 作为外部只读数据源处理,不执行迁移、写入、恢复或 checkpoint。 + 读取事务包含 WAL 中已提交的数据,并将锁等待限制为 250 ms。数据库缺失、 + 被锁定、损坏或不兼容时,旧版 JSON 扫描仍可继续。选中会话转换失败时, + 会在宿主导入前整体失败,不会静默保存不完整的记录。 +- 支持的 SQLite 结构为 OpenCode v1 的 `session`、`message` 和 `part` 表, + 已按 v1.18.26(`774cc7c`)核对。会话和消息使用完整的键集分页读取;消息按 + `(time_created, id)` 排序,部件按 `id` 排序。来源 ID、时间戳、项目目录和 + 助手模型元数据沿用现有导入语义。可见的非合成文本,以及工具输入、输出和 + 错误仍由现有转换器处理;推理、附件和结构性部件不在内容导入范围内。 + 独立的 v2 `session_message` 投影暂不导入。 + ### 项目档案 - 重用持久项目索引作为设置规模管理界面 - 始终包含存档记录;归档行被分组,从不隐藏,所以 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index c600c7b1fc..f7af85ab49 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -8158,3 +8158,25 @@ the latest destination. These assertions measure work counts, not device FPS. - **阶段:** 发布后维护。 - **自动化:** `pnpm test:e2e:dialog-overflow`;源代码检查不能替代实际布局验证。 - **状态:** 已实现,原生 Windows 已验证,macOS/Linux 尚未实机验证。 + +### E2E-IMPORT-opencode-sqlite + +- **来源契约:** OpenCode v1.18.26,提交 + `774cc7c1914e4329eefde5a669f938b0cf566661`: + [数据库结构](https://github.com/anomalyco/opencode/blob/774cc7c1914e4329eefde5a669f938b0cf566661/packages/core/src/session/sql.ts) + 和 [v1 读取器](https://github.com/anomalyco/opencode/blob/774cc7c1914e4329eefde5a669f938b0cf566661/packages/opencode/src/session/message-v2.ts)。 +- **前置条件:** 使用隔离的临时来源目录和真实 SQLite 测试数据,不读取开发者或 + 用户的智能体数据库,也不使用提供商凭据。 +- **步骤:** 扫描仅含数据库的存储,选择并转换其中的会话。依次覆盖绝对 XDG + 路径、残留 JSON 副本、超过 256 个会话、超过 512 条且时间戳相同的消息、 + 逆序写入的部件、助手模型元数据,以及成功和失败的工具调用。在 WAL 写入方 + 同时存在已提交和未提交数据时执行扫描;再覆盖数据库缺失、被锁定、损坏或 + 结构不完整,以及选中后会话被删除或损坏的情况。 +- **预期:** 仅存于数据库的会话可以被发现;ID 重复时以当前数据库副本为准, + 仅存于 JSON 的会话仍可导入,分页不丢失记录,消息和部件按 v1 契约遍历, + 且来源 DB/WAL 字节不发生变化。其他会话的部件不得泄漏。记录损坏或被删除时 + 转换整体失败,不产生部分导入;SQLite 错误不阻断旧版扫描。含路径分隔符的 + ID 会被拒绝。推理、附件和 v2 `session_message` 投影保持文档所述的不支持边界。 +- **自动化:** `node --test apps/desktop/test/importer-opencode.test.mjs` + 使用真实文件系统和 SQLite 边界测试生产导入服务;不代表已完成 Windows + 原生环境或完整 Electron UI 流程验证。