Skip to content
Open
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
4 changes: 2 additions & 2 deletions packages/core/src/database/sqlite.bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
}),
)
}
Expand All @@ -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" }),
}),
)
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/database/sqlite.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
}),
)
}
Expand All @@ -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" }),
}),
)
}
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/database/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,15 @@ import type { drizzle } from "drizzle-orm/bun-sqlite"
export type DrizzleClient = ReturnType<typeof drizzle>
export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}

export function executeErrorMessage(cause: unknown) {
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
}
37 changes: 37 additions & 0 deletions packages/core/test/database-sqlite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 SQLite execution error codes", 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).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")
})
11 changes: 7 additions & 4 deletions packages/opencode/src/bus/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlobalBusEvents> {
override emit: EventEmitter<GlobalBusEvents>["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)
}
}

Expand Down
11 changes: 9 additions & 2 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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])
7 changes: 4 additions & 3 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -1526,6 +1530,33 @@ describe("session.message-v2.fromError", () => {
})
})

test("surfaces SQLite details without query parameters", () => {
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"],
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")
expect(JSON.stringify(result)).not.toContain("native-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()',
Expand Down
Loading