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
17 changes: 17 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,26 @@ export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFo
{ httpApiStatus: 404 },
) {}

export class ApiDuplicateIDError extends Schema.ErrorClass<ApiDuplicateIDError>("DuplicateIDError")(
{
name: Schema.Literal("DuplicateIDError"),
data: Schema.Struct({
id: Schema.String,
}),
},
{ httpApiStatus: 409 },
) {}

export function notFound(message: string) {
return new ApiNotFoundError({
name: "NotFoundError",
data: { message },
})
}

export function duplicateID(id: string) {
return new ApiDuplicateIDError({
name: "DuplicateIDError",
data: { id },
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors"
import { ApiDuplicateIDError, ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors"
import { described } from "./metadata"
import { QueryBoolean } from "./query"

Expand Down Expand Up @@ -200,7 +200,7 @@ export const SessionApi = HttpApi.make("session")
query: WorkspaceRoutingQuery,
payload: [HttpApiSchema.NoContent, Session.CreateInput],
success: described(Session.Info, "Successfully created session"),
error: HttpApiError.BadRequest,
error: [HttpApiError.BadRequest, ApiDuplicateIDError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.create",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { Session } from "@/session/session"
import { Effect } from "effect"
import * as ApiError from "../errors"

type DuplicateID = InstanceType<typeof Session.DuplicateIDError>

export function mapStorageNotFound<A, R>(self: Effect.Effect<A, StorageNotFoundError, R>) {
return self.pipe(Effect.mapError((error) => ApiError.notFound(error.message)))
}
Expand All @@ -19,3 +21,7 @@ export function mapBusy<A, R>(self: Effect.Effect<A, Session.BusyError, R>) {
),
)
}

export function mapDuplicateID<A, R>(self: Effect.Effect<A, DuplicateID, R>) {
return self.pipe(Effect.mapError((error) => ApiError.duplicateID(error.data.id)))
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})

const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload?: Session.CreateInput }) {
return yield* shareSvc.create(ctx.payload)
return yield* SessionError.mapDuplicateID(shareSvc.create(ctx.payload))
})

const createRaw = Effect.fn("SessionHttpApi.createRaw")(function* (ctx: {
Expand Down
16 changes: 13 additions & 3 deletions packages/opencode/src/session/projectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ function foreign(err: unknown) {
if ("code" in err && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") return true
return "message" in err && typeof err.message === "string" && err.message.includes("FOREIGN KEY constraint failed")
}
function duplicate(err: unknown, table: string) {
if (typeof err !== "object" || err === null) return false
if (!("message" in err) || typeof err.message !== "string") return false
return err.message.includes(`UNIQUE constraint failed: ${table}.id`)
}

export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T

Expand Down Expand Up @@ -98,9 +103,14 @@ export function toPartialRow(info: DeepPartial<Session.Info>) {

export default [
SyncEvent.project(Session.Event.Created, (db, data) => {
db.insert(SessionTable)
.values(Session.toRow(data.info as Session.Info))
.run()
try {
db.insert(SessionTable)
.values(Session.toRow(data.info as Session.Info))
.run()
} catch (err) {
if (duplicate(err, "session")) throw new Session.DuplicateIDError({ id: data.info.id })
throw err
}

if (data.info.workspaceID) {
db.update(WorkspaceTable).set({ time_used: Date.now() }).where(eq(WorkspaceTable.id, data.info.workspaceID)).run()
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { PartTable, SessionTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { MessageV2 } from "./message-v2"
import type { InstanceContext } from "../project/instance-context"
import { InstanceState } from "@/effect/instance-state"
Expand Down Expand Up @@ -242,6 +243,7 @@ export type GlobalInfo = Types.DeepMutable<Schema.Schema.Type<typeof GlobalInfo>

export const CreateInput = Schema.optional(
Schema.Struct({
id: Schema.optional(SessionID),
parentID: Schema.optional(SessionID),
title: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
Expand Down Expand Up @@ -448,9 +450,14 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("SessionBusy

export type NotFound = NotFoundError

export const DuplicateIDError = NamedError.create("DuplicateIDError", {
id: Schema.String,
})

export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly create: (input?: {
id?: SessionID
parentID?: SessionID
title?: string
agent?: string
Expand Down Expand Up @@ -655,6 +662,7 @@ export const layer: Layer.Layer<
})

const create = Effect.fn("Session.create")(function* (input?: {
id?: SessionID
parentID?: SessionID
title?: string
agent?: string
Expand All @@ -665,6 +673,7 @@ export const layer: Layer.Layer<
const ctx = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
return yield* createNext({
id: input?.id,
parentID: input?.parentID,
directory: ctx.directory,
path: sessionPath(ctx.worktree, ctx.directory),
Expand Down
46 changes: 43 additions & 3 deletions packages/opencode/test/session/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Layer } from "effect"
import { Cause, Deferred, Effect, Exit, Layer } from "effect"
import { Session as SessionNs } from "@/session/session"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import * as Log from "@opencode-ai/core/util/log"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
Expand Down Expand Up @@ -35,7 +35,7 @@ const awaitDeferred = <T>(deferred: Deferred.Deferred<T>, message: string) =>
Effect.sleep("2 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error(message)))),
)

const remove = (id: SessionID) => SessionNs.use.remove(id)
const remove = (id: SessionIDType) => SessionNs.use.remove(id)

const subscribeGlobal = (type: string, callback: (event: NonNullable<GlobalEvent["payload"]>) => void) => {
const listener = (event: GlobalEvent) => {
Expand Down Expand Up @@ -185,3 +185,43 @@ describe("Session", () => {
}),
)
})

describe("custom session ID", () => {
it.instance("round-trip: create with custom id returns same id and is retrievable", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const customID = SessionID.descending()
const created = yield* session.create({ id: customID })
const fetched = yield* session.get(customID)

expect(created.id).toBe(customID)
expect(fetched.id).toBe(customID)
}),
)

it.instance("creating with duplicate id throws DuplicateIDError", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const customID = SessionID.descending()
yield* session.create({ id: customID })

const exit = yield* session.create({ id: customID }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.squash(exit.cause)).toMatchObject({ name: "DuplicateIDError", data: { id: customID } })
}
}),
)

it.instance("creating with malformed id (wrong prefix) throws", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const exit = yield* session.create({ id: "not-a-session-id" as never }).pipe(Effect.exit)

expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.pretty(exit.cause)).toContain('Expected a string starting with "ses"')
}
}),
)
})
2 changes: 2 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3092,6 +3092,7 @@ export class Session2 extends HeyApiClient {
parameters?: {
directory?: string
workspace?: string
id?: string
parentID?: string
title?: string
agent?: string
Expand All @@ -3112,6 +3113,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "id" },
{ in: "body", key: "parentID" },
{ in: "body", key: "title" },
{ in: "body", key: "agent" },
Expand Down
Loading
Loading