refactor(core): page session lists in core (#56243)

This commit is contained in:
Aiden Cline
2026-10-16 23:11:22 -05:00
committed by GitHub
Unverified
parent ae6a706156
commit 1cb7bcadb1
4 changed files with 130 additions and 49 deletions
+41 -9
View File
@@ -1,9 +1,9 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { LLMClient } from "@opencode/ai"
import { ListAnchor, ListCursor } from "@opencode/schema/session"
import { ListAnchor, ListCursor, ListFilter } from "@opencode/schema/session"
import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
import { Model } from "@opencode/schema/model"
@@ -32,6 +32,7 @@ import {
CompactionConflictError,
ForkEmptyError,
InboxConflictError,
InvalidListCursorError,
MessageDecodeError,
MessageNotFoundError,
NotFoundError,
@@ -80,6 +81,8 @@ export { ListAnchor, ListCursor }
export const ListInput = SessionStore.ListInput
export type ListInput = SessionStore.ListInput
const DefaultListLimit = 50
type CreateBaseInput = {
id?: SessionSchema.ID
title?: string
@@ -103,6 +106,7 @@ export {
BusyError,
CompactionConflictError,
InboxConflictError,
InvalidListCursorError,
MessageDecodeError,
MessageNotFoundError,
NotFoundError,
@@ -116,9 +120,14 @@ export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnav
export { TurnRangeError }
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
readonly data: SessionSchema.Info[]
}>
/** A cursor carries the filters of the request that produced it, so filters passed with a cursor are ignored. */
readonly list: (input?: ListFilter & { readonly limit?: number; readonly cursor?: ListCursor }) => Effect.Effect<
{
readonly data: SessionSchema.Info[]
readonly cursor: { readonly previous?: ListCursor; readonly next?: ListCursor }
},
InvalidListCursorError
>
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly fork: (
input: ForkInput,
@@ -369,14 +378,37 @@ const layer = Layer.effect(
yield* execution.interrupt(sessionID)
yield* execution.awaitIdle(sessionID)
yield* transport.close(sessionID)
const children = yield* result.list({ parentID: sessionID })
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
const children = yield* store.list({ parentID: sessionID })
yield* Effect.forEach(children, (child) => result.remove(child.id), { concurrency: 1, discard: true })
yield* environments.clear(sessionID)
yield* bus.publish(SessionEvent.Deleted, { sessionID })
yield* bus.remove(sessionID)
}),
list: Effect.fn("Session.list")(function* (input) {
return { data: yield* store.list(input) }
list: Effect.fn("Session.list")(function* (input = {}) {
const filter =
input.cursor === undefined
? input
: yield* ListCursor.parse(input.cursor).pipe(Effect.mapError(() => new InvalidListCursorError()))
const data = yield* store.list({ ...filter, limit: input.limit ?? DefaultListLimit })
const first = data[0]
const last = data.at(-1)
return {
data,
cursor: {
previous: first
? ListCursor.make({
...filter,
anchor: { id: first.id, time: DateTime.toEpochMillis(first.time.updated), direction: "previous" },
})
: undefined,
next: last
? ListCursor.make({
...filter,
anchor: { id: last.id, time: DateTime.toEpochMillis(last.time.updated), direction: "next" },
})
: undefined,
},
}
}),
messages: Effect.fn("Session.messages")(function* (input) {
yield* result.get(input.sessionID)
+9
View File
@@ -11,6 +11,15 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Session.
sessionID: SessionSchema.ID,
}) {}
export class InvalidListCursorError extends Schema.TaggedError<InvalidListCursorError>()(
"Session.InvalidListCursorError",
{},
) {
override get message() {
return "Invalid cursor"
}
}
export class MessageNotFoundError extends Schema.TaggedError<MessageNotFoundError>()("Session.MessageNotFoundError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Location } from "@opencode/core/location"
import { Project } from "@opencode/core/project"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionProjector } from "@opencode/core/session/projector"
import { SessionStore } from "@opencode/core/session/store"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Effect, Schema } from "effect"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
Bus.node.replace(Bus.configured({ persist: true })),
Project.node.replace(globalProjectNode),
SessionExecution.node.replace(SessionExecution.noopLayer),
],
),
)
const project = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const other = Location.Ref.make({ directory: AbsolutePath.make("/other") })
describe("Session.list", () => {
it.effect("pages through the filtered store results in both directions", () =>
Effect.gen(function* () {
const session = yield* Session.Service
yield* Effect.forEach(["match one", "match two", "match three"], (title) =>
session.create({ location: project, title }),
)
yield* session.create({ location: project, title: "different" })
yield* session.create({ location: other, title: "match elsewhere" })
const filter = { directory: project.directory, search: "match", order: "asc" as const }
const store = yield* SessionStore.Service
const expected = (yield* store.list(filter)).map((item) => item.id)
const first = yield* session.list({ ...filter, limit: 2 })
const second = yield* session.list({ cursor: first.cursor.next, limit: 2 })
const back = yield* session.list({ cursor: second.cursor.previous, limit: 2 })
const end = yield* session.list({ cursor: second.cursor.next, limit: 2 })
expect(expected).toHaveLength(3)
expect([...first.data, ...second.data].map((item) => item.id)).toEqual(expected)
expect(back.data.map((item) => item.id)).toEqual(expected.slice(0, 2))
expect(end).toEqual({ data: [], cursor: { previous: undefined, next: undefined } })
}),
)
it.effect("returns 50 sessions when no limit is given", () =>
Effect.gen(function* () {
const session = yield* Session.Service
yield* Effect.forEach(Array.from({ length: 51 }), () => session.create({ location: project }), { discard: true })
expect((yield* session.list()).data).toHaveLength(50)
}),
)
it.effect("rejects an invalid cursor", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const error = yield* session.list({ cursor: Schema.decodeSync(Session.ListCursor)("invalid") }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Session.InvalidListCursorError)
}),
)
})
+8 -40
View File
@@ -25,8 +25,6 @@ import { AbsolutePath } from "@opencode/core/schema"
import { locationErrors } from "../location"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
const DefaultSessionsLimit = 50
function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
}
@@ -60,44 +58,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.list",
Effect.fn(function* (ctx) {
const query =
ctx.query.cursor !== undefined
? yield* Session.ListCursor.parse(ctx.query.cursor).pipe(
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
)
: ctx.query
const page = yield* session.list({
...query,
limit: ctx.query.limit ?? DefaultSessionsLimit,
})
const sessions = page.data
const first = sessions[0]
const last = sessions.at(-1)
return {
data: sessions,
cursor: {
previous: first
? Session.ListCursor.make({
...query,
anchor: {
id: first.id,
time: DateTime.toEpochMillis(first.time.updated),
direction: "previous",
},
})
: undefined,
next: last
? Session.ListCursor.make({
...query,
anchor: {
id: last.id,
time: DateTime.toEpochMillis(last.time.updated),
direction: "next",
},
})
: undefined,
},
}
return yield* session
.list(ctx.query)
.pipe(
Effect.catchTag(
"Session.InvalidListCursorError",
(error) => new InvalidCursorError({ message: error.message }),
),
)
}),
)
.handle(