mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
refactor(core): page session lists in core (#56243)
This commit is contained in:
committed by
GitHub
Unverified
parent
ae6a706156
commit
1cb7bcadb1
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user