From 9c25c04d02bc5bd0aff50035fabcdbc95fecefb9 Mon Sep 17 00:00:00 2001 From: bvolpato Date: Sun, 12 Jul 2026 18:04:02 -0400 Subject: [PATCH 1/3] fix(core): preserve sqlite error details --- packages/core/src/database/sqlite.bun.ts | 4 +-- packages/core/src/database/sqlite.node.ts | 4 +-- packages/core/src/database/sqlite.ts | 5 ++++ packages/core/test/database-sqlite.test.ts | 25 +++++++++++++++++ packages/opencode/src/session/message-v2.ts | 11 ++++++-- packages/opencode/src/session/processor.ts | 7 +++-- .../opencode/test/session/message-v2.test.ts | 28 +++++++++++++++++++ 7 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 packages/core/test/database-sqlite.test.ts diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index e15f4c117e46..15955e9b819f 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -63,7 +63,7 @@ const make = (options: Config) => } catch (cause) { return Effect.fail( new SqlError({ - reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + reason: classifySqliteError(cause, { message: Sqlite.executeErrorMessage(cause), operation: "execute" }), }), ) } @@ -79,7 +79,7 @@ const make = (options: Config) => } catch (cause) { return Effect.fail( new SqlError({ - reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + reason: classifySqliteError(cause, { message: Sqlite.executeErrorMessage(cause), operation: "execute" }), }), ) } diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 6eaecbee26ea..4daddc93ed12 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -62,7 +62,7 @@ const make = (options: Config) => } catch (cause) { return Effect.fail( new SqlError({ - reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + reason: classifySqliteError(cause, { message: Sqlite.executeErrorMessage(cause), operation: "execute" }), }), ) } @@ -80,7 +80,7 @@ const make = (options: Config) => } catch (cause) { return Effect.fail( new SqlError({ - reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + reason: classifySqliteError(cause, { message: Sqlite.executeErrorMessage(cause), operation: "execute" }), }), ) } diff --git a/packages/core/src/database/sqlite.ts b/packages/core/src/database/sqlite.ts index d2304a54737a..8c202a144f9a 100644 --- a/packages/core/src/database/sqlite.ts +++ b/packages/core/src/database/sqlite.ts @@ -6,3 +6,8 @@ import type { drizzle } from "drizzle-orm/bun-sqlite" export type DrizzleClient = ReturnType export class Native extends Context.Service()("@opencode-ai/core/database/SqliteNative") {} export class Drizzle extends Context.Service()("@opencode-ai/core/database/SqliteDrizzle") {} + +export function executeErrorMessage(cause: unknown) { + if (cause instanceof Error && cause.message) return `Failed to execute statement: ${cause.message}` + return "Failed to execute statement" +} diff --git a/packages/core/test/database-sqlite.test.ts b/packages/core/test/database-sqlite.test.ts new file mode 100644 index 000000000000..2a47c1b79792 --- /dev/null +++ b/packages/core/test/database-sqlite.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test" +import { Database } from "@opencode-ai/core/database/database" +import { Cause, Effect } from "effect" +import { sql } from "drizzle-orm" +import path from "path" +import { tmpdir } from "./fixture/tmpdir" + +test("preserves native SQLite execution error details", async () => { + await using tmp = await tmpdir() + + await Effect.runPromise( + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db.run(sql`CREATE TABLE diagnostic (value text UNIQUE)`) + yield* db.run(sql`INSERT INTO diagnostic (value) VALUES ('duplicate')`) + const error = yield* db.run(sql`INSERT INTO diagnostic (value) VALUES ('duplicate')`).pipe(Effect.flip) + + if (!Cause.isCause(error.cause)) throw new Error("Expected query cause") + const cause = Cause.squash(error.cause) + if (!(cause instanceof Error)) throw new Error("Expected SQLite cause") + expect(cause.message).toContain("Failed to execute statement") + expect(cause.message).toContain("UNIQUE constraint failed") + }).pipe(Effect.provide(Database.layerFromPath(path.join(tmp.path, "error.sqlite"))), Effect.scoped), + ) +}) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1bea9f52c3ec..554f8718c9f4 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -34,7 +34,8 @@ import { errorMessage } from "@/util/error" import { isMedia } from "@/util/media" import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" -import { Effect, Schema } from "effect" +import { Cause, Effect, Schema } from "effect" +import { isSqlError } from "effect/unstable/sql/SqlError" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -700,7 +701,7 @@ export function fromError( { cause: e }, ).toObject() case e instanceof Error: - return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject() + return new NamedError.Unknown({ message: sqlErrorMessage(e) ?? errorMessage(e) }, { cause: e }).toObject() default: try { const parsed = ProviderError.parseStreamError(e) @@ -730,5 +731,11 @@ export function fromError( } } +export function sqlErrorMessage(error: unknown): string | undefined { + if (isSqlError(error)) return error.message + if (!(error instanceof Error) || !Cause.isCause(error.cause)) return + return sqlErrorMessage(Cause.squash(error.cause)) +} + export * as MessageV2 from "./message-v2" export const node = LayerNode.group([Database.node]) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404d8..4a9fa29e72f7 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -597,13 +597,14 @@ const layer = Layer.effect( }) const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { + const error = parse(e) + const sqlError = MessageV2.sqlErrorMessage(e) yield* Effect.logError("process", { "session.id": input.sessionID, messageID: input.assistantMessage.id, - error: errorMessage(e), - stack: e instanceof Error ? e.stack : undefined, + error: sqlError ?? errorMessage(e), + stack: sqlError ? undefined : e instanceof Error ? e.stack : undefined, }) - const error = parse(e) if (SessionV1.ContextOverflowError.isInstance(error)) { if ((yield* config.get()).compaction?.auto === false && !ctx.assistantMessage.summary) { ctx.assistantMessage.error = error diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 9bb688aeddb0..86d9d0c9d83e 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test" +import { Sqlite } from "@opencode-ai/core/database/sqlite" import { SessionV1 } from "@opencode-ai/core/v1/session" import { APICallError } from "ai" +import { EffectDrizzleQueryError } from "drizzle-orm/effect-core/errors" +import { Cause } from "effect" +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" import { MessageV2 } from "../../src/session/message-v2" import { ProviderTransform } from "@/provider/transform" import type { Provider } from "@/provider/provider" @@ -1526,6 +1530,30 @@ describe("session.message-v2.fromError", () => { }) }) + test("surfaces SQLite details without query parameters", () => { + const cause = Object.assign(new Error("database or disk is full"), { code: "SQLITE_FULL" }) + const error = new EffectDrizzleQueryError({ + query: "INSERT INTO message (data) VALUES (?)", + params: ["sensitive-value"], + cause: Cause.fail( + new SqlError({ + reason: classifySqliteError(cause, { + message: Sqlite.executeErrorMessage(cause), + operation: "execute", + }), + }), + ), + }) + + const result = MessageV2.fromError(error, { providerID }) + + expect(result).toStrictEqual({ + name: "UnknownError", + data: { message: "Failed to execute statement: database or disk is full" }, + }) + expect(JSON.stringify(result)).not.toContain("sensitive-value") + }) + test("classifies ZlibError from fetch as retryable APIError", () => { const zlibError = new Error( 'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()', From fbd086661307e877dc1075c8e6ebc2a837c924d7 Mon Sep 17 00:00:00 2001 From: bvolpato Date: Sun, 12 Jul 2026 18:05:21 -0400 Subject: [PATCH 2/3] fix(opencode): preserve global bus emit signature --- packages/opencode/src/bus/global.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index 3cfd453624c1..134d70ee26a1 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -8,14 +8,17 @@ export type GlobalEvent = { payload: any } -class GlobalBusEmitter extends EventEmitter<{ +type GlobalBusEvents = { event: [GlobalEvent] -}> { - override emit(eventName: "event", event: GlobalEvent): boolean { +} + +class GlobalBusEmitter extends EventEmitter { + override emit: EventEmitter["emit"] = (eventName, ...args) => { + const event = args[0] if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) { event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending") } - return super.emit(eventName, event) + return super.emit(eventName, ...args) } } From e6f02002c573d7989948122a2bbb5b1c4ff27125 Mon Sep 17 00:00:00 2001 From: bvolpato Date: Sun, 12 Jul 2026 18:32:37 -0400 Subject: [PATCH 3/3] fix(core): sanitize sqlite error details --- packages/core/src/database/sqlite.ts | 11 +++++++++-- packages/core/test/database-sqlite.test.ts | 18 +++++++++++++++--- .../opencode/test/session/message-v2.test.ts | 5 ++++- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/core/src/database/sqlite.ts b/packages/core/src/database/sqlite.ts index 8c202a144f9a..89d290d4a751 100644 --- a/packages/core/src/database/sqlite.ts +++ b/packages/core/src/database/sqlite.ts @@ -8,6 +8,13 @@ export class Native extends Context.Service()("@opencode-ai/cor export class Drizzle extends Context.Service()("@opencode-ai/core/database/SqliteDrizzle") {} export function executeErrorMessage(cause: unknown) { - if (cause instanceof Error && cause.message) return `Failed to execute statement: ${cause.message}` - return "Failed to execute statement" + const fallback = "Failed to execute statement" + if (!(cause instanceof Error)) return fallback + const code = "code" in cause && typeof cause.code === "string" ? cause.code : undefined + const errcode = "errcode" in cause && typeof cause.errcode === "number" ? cause.errcode : undefined + if (code === "SQLITE_FULL" || (errcode !== undefined && (errcode & 0xff) === 13)) + return `${fallback}: database or disk is full` + if (!code) return fallback + if (/^(?:SQLITE|ERR_SQLITE)_[A-Z0-9_]+$/.test(code)) return `${fallback} (${code})` + return fallback } diff --git a/packages/core/test/database-sqlite.test.ts b/packages/core/test/database-sqlite.test.ts index 2a47c1b79792..9a2bc707cfdb 100644 --- a/packages/core/test/database-sqlite.test.ts +++ b/packages/core/test/database-sqlite.test.ts @@ -1,11 +1,12 @@ import { expect, test } from "bun:test" import { Database } from "@opencode-ai/core/database/database" +import { Sqlite } from "@opencode-ai/core/database/sqlite" import { Cause, Effect } from "effect" import { sql } from "drizzle-orm" import path from "path" import { tmpdir } from "./fixture/tmpdir" -test("preserves native SQLite execution error details", async () => { +test("preserves SQLite execution error codes", async () => { await using tmp = await tmpdir() await Effect.runPromise( @@ -18,8 +19,19 @@ test("preserves native SQLite execution error details", async () => { if (!Cause.isCause(error.cause)) throw new Error("Expected query cause") const cause = Cause.squash(error.cause) if (!(cause instanceof Error)) throw new Error("Expected SQLite cause") - expect(cause.message).toContain("Failed to execute statement") - expect(cause.message).toContain("UNIQUE constraint failed") + expect(cause.message).toBe("Failed to execute statement (SQLITE_CONSTRAINT_UNIQUE)") + expect(cause.message).not.toContain("diagnostic.value") }).pipe(Effect.provide(Database.layerFromPath(path.join(tmp.path, "error.sqlite"))), Effect.scoped), ) }) + +test("reports disk exhaustion without copying the native message", () => { + const bun = Object.assign(new Error("database or disk is full: sensitive-value"), { code: "SQLITE_FULL" }) + const node = Object.assign(new Error("database or disk is full: sensitive-value"), { + code: "ERR_SQLITE_ERROR", + errcode: 13, + }) + + expect(Sqlite.executeErrorMessage(bun)).toBe("Failed to execute statement: database or disk is full") + expect(Sqlite.executeErrorMessage(node)).toBe("Failed to execute statement: database or disk is full") +}) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 86d9d0c9d83e..dacb0bc1bafb 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1531,7 +1531,9 @@ describe("session.message-v2.fromError", () => { }) test("surfaces SQLite details without query parameters", () => { - const cause = Object.assign(new Error("database or disk is full"), { code: "SQLITE_FULL" }) + const cause = Object.assign(new Error("database or disk is full: native-sensitive-value"), { + code: "SQLITE_FULL", + }) const error = new EffectDrizzleQueryError({ query: "INSERT INTO message (data) VALUES (?)", params: ["sensitive-value"], @@ -1552,6 +1554,7 @@ describe("session.message-v2.fromError", () => { data: { message: "Failed to execute statement: database or disk is full" }, }) expect(JSON.stringify(result)).not.toContain("sensitive-value") + expect(JSON.stringify(result)).not.toContain("native-sensitive-value") }) test("classifies ZlibError from fetch as retryable APIError", () => {