diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index a866d8dec15..f0af4554666 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -15,7 +15,7 @@ import { Database } from "./database/database" import { SessionProjector } from "./session/projector" import { SessionMessageTable, SessionTable } from "./session/sql" import { SessionSchema } from "./session/schema" -import { AbsolutePath, RelativePath } from "./schema" +import { AbsolutePath, PositiveInt, RelativePath } from "./schema" import { AgentV2 } from "./agent" // get project -> project.locations @@ -27,35 +27,35 @@ import { AgentV2 } from "./agent" // - by subpath // - by workspace (home is special) -export const ListCursor = Schema.Struct({ +export const ListAnchor = Schema.Struct({ id: SessionSchema.ID, time: Schema.Finite, direction: Schema.Literals(["previous", "next"]), }) -export type ListCursor = typeof ListCursor.Type +export type ListAnchor = typeof ListAnchor.Type const ListInputBase = { workspaceID: WorkspaceV2.ID.pipe(Schema.optional), search: Schema.String.pipe(Schema.optional), - limit: Schema.Int.pipe(Schema.optional), + limit: PositiveInt.pipe(Schema.optional), order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional), - cursor: ListCursor.pipe(Schema.optional), + anchor: ListAnchor.pipe(Schema.optional), } -export const ListInput = Schema.Union([ - Schema.Struct({ - ...ListInputBase, - }), - Schema.Struct({ - ...ListInputBase, - directory: AbsolutePath, - }), - Schema.Struct({ - ...ListInputBase, - project: ProjectV2.ID, - subpath: RelativePath.pipe(Schema.optional), - }), -]) +const ListDirectoryInput = Schema.Struct({ + ...ListInputBase, + directory: AbsolutePath, +}) + +const ListProjectInput = Schema.Struct({ + ...ListInputBase, + project: ProjectV2.ID, + subpath: RelativePath.pipe(Schema.optional), +}) + +const ListAllInput = Schema.Struct(ListInputBase) + +export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput]) export type ListInput = typeof ListInput.Type type CreateInput = { @@ -205,25 +205,25 @@ export const layer = Layer.effect( return fromRow(row) }), list: Effect.fn("V2Session.list")(function* (input = {}) { - const direction = input.cursor?.direction ?? "next" + const direction = input.anchor?.direction ?? "next" const requestedOrder = input.order ?? "desc" const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder - const sortColumn = SessionTable.time_updated + const sortColumn = SessionTable.time_created const conditions: SQL[] = [] if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) - if (input.cursor) { + if (input.anchor) { conditions.push( order === "asc" ? or( - gt(sortColumn, input.cursor.time), - and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)), + gt(sortColumn, input.anchor.time), + and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)), )! : or( - lt(sortColumn, input.cursor.time), - and(eq(sortColumn, input.cursor.time), lt(SessionTable.id, input.cursor.id)), + lt(sortColumn, input.anchor.time), + and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)), )!, ) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index da228445c04..7ec6b9875a7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -2,7 +2,10 @@ import { SessionID } from "@/session/schema" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionV2 } from "@opencode-ai/core/session" -import { Schema } from "effect" +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { InvalidCursorError, @@ -12,29 +15,73 @@ import { UnknownError, } from "../../errors" import { V2Authorization } from "../../middleware/authorization" -import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" -import { QueryBoolean } from "../query" +import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing" -export const SessionsQuery = Schema.Struct({ - ...WorkspaceRoutingQueryFields, - limit: Schema.optional( - Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), - ).annotate({ +const SessionsQueryFields = { + workspace: WorkspaceV2.ID.pipe(Schema.optional), + limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({ description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", }), order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ description: "Session order for the first page. Use desc for newest first or asc for oldest first.", }), - path: Schema.optional(Schema.String), - roots: Schema.optional(QueryBoolean), - start: Schema.optional(Schema.NumberFromString), search: Schema.optional(Schema.String), - cursor: Schema.optional( - Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.", - }), - ), +} + +const SessionsDirectoryQuery = Schema.Struct({ + ...SessionsQueryFields, + directory: AbsolutePath, +}) + +const SessionsProjectQuery = Schema.Struct({ + ...SessionsQueryFields, + project: ProjectV2.ID, + subpath: RelativePath.pipe(Schema.optional), +}) + +const SessionsAllQuery = Schema.Struct(SessionsQueryFields) + +const withCursor = (schema: Schema.Struct) => + schema.mapFields((fields) => ({ + ...Struct.omit(fields, ["limit"]), + anchor: SessionV2.ListAnchor, + })) + +const SessionsCursorInput = Schema.Union([ + withCursor(SessionsDirectoryQuery), + withCursor(SessionsProjectQuery), + withCursor(SessionsAllQuery), +]) +const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput) +const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson) +const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson) + +export const SessionsCursor = Schema.String.pipe( + Schema.brand("V2SessionsCursor"), + withStatics((schema) => { + const make = schema.make + return { + make: (input: typeof SessionsCursorInput.Type) => + make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")), + parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")), + } + }), +) +export type SessionsCursor = typeof SessionsCursor.Type + +const SessionsCursorQuery = Schema.Struct({ + cursor: SessionsCursor.annotate({ + description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", + }), + limit: SessionsQueryFields.limit, +}) + +export const SessionsQuery = Schema.Struct({ + ...SessionsQueryFields, + directory: AbsolutePath.pipe(Schema.optional), + project: ProjectV2.ID.pipe(Schema.optional), + subpath: RelativePath.pipe(Schema.optional), + cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional), }).annotate({ identifier: "V2SessionsQuery" }) export const SessionGroup = HttpApiGroup.make("v2.session") @@ -44,8 +91,8 @@ export const SessionGroup = HttpApiGroup.make("v2.session") success: Schema.Struct({ items: Schema.Array(SessionV2.Info), cursor: Schema.Struct({ - previous: Schema.String.pipe(Schema.optional), - next: Schema.String.pipe(Schema.optional), + previous: SessionsCursor.pipe(Schema.optional), + next: SessionsCursor.pipe(Schema.optional), }), }).annotate({ identifier: "V2SessionsResponse" }), error: [InvalidCursorError, InvalidRequestError], diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index f6f12633523..6befb471ad6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -1,95 +1,12 @@ -import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionV2 } from "@opencode-ai/core/session" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { DateTime, Effect, Option, Schema } from "effect" +import { DateTime, Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" -import { - InvalidCursorError, - InvalidRequestError, - ServiceUnavailableError, - SessionNotFoundError, - UnknownError, -} from "../../errors" +import { SessionsCursor } from "../../groups/v2/session" +import { InvalidCursorError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors" const DefaultSessionsLimit = 50 -const SessionCursor = Schema.Struct({ - id: SessionV2.Info.fields.id, - time: Schema.Finite, - order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), - direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), - directory: Schema.String.pipe(Schema.optional), - path: Schema.String.pipe(Schema.optional), - workspaceID: WorkspaceV2.ID.pipe(Schema.optional), - roots: Schema.Boolean.pipe(Schema.optional), - start: Schema.Finite.pipe(Schema.optional), - search: Schema.String.pipe(Schema.optional), -}) -type SessionCursor = typeof SessionCursor.Type - -const decodeCursor = Schema.decodeUnknownSync(SessionCursor) - -function hasCursorFilter(query: { - readonly order?: unknown - readonly path?: unknown - readonly roots?: unknown - readonly start?: unknown - readonly search?: unknown -}) { - return ( - query.order !== undefined || - query.path !== undefined || - query.roots !== undefined || - query.start !== undefined || - query.search !== undefined - ) -} - -function hasCursorRoutingMismatch( - query: { readonly directory?: string; readonly workspace?: string }, - decoded: SessionCursor | undefined, -) { - if (!decoded) return false - if (query.directory !== undefined && query.directory !== decoded.directory) return true - return query.workspace !== undefined && query.workspace !== decoded.workspaceID -} - -const sessionCursor = { - encode( - session: SessionV2.Info, - order: "asc" | "desc", - direction: "previous" | "next", - filters: Pick, - ) { - return Buffer.from( - JSON.stringify({ - ...filters, - id: session.id, - time: DateTime.toEpochMillis(session.time.updated), - order, - direction, - }), - ).toString("base64url") - }, - decode(input: string) { - return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) - }, -} - -function decodeWorkspaceID(input: string | undefined) { - if (input === undefined) return Effect.succeed(undefined) - const workspaceID = Schema.decodeUnknownOption(WorkspaceV2.ID)(input) - if (Option.isSome(workspaceID)) return Effect.succeed(workspaceID.value) - return Effect.fail( - new InvalidRequestError({ - message: "Invalid workspace query parameter", - kind: "Query", - field: "workspace", - }), - ) -} - export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service @@ -98,45 +15,42 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "sessions", Effect.fn(function* (ctx) { - if (ctx.query.cursor && hasCursorFilter(ctx.query)) - return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order or filters" }) - const decoded = yield* Effect.try({ - try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), - catch: () => new InvalidCursorError({ message: "Invalid cursor" }), - }) - if (hasCursorRoutingMismatch(ctx.query, decoded)) - return yield* new InvalidCursorError({ message: "Cursor does not match requested directory or workspace" }) - const order = decoded?.order ?? ctx.query.order ?? "desc" - const filters = decoded ?? { - directory: ctx.query.directory, - path: ctx.query.path, - workspaceID: yield* decodeWorkspaceID(ctx.query.workspace), - roots: ctx.query.roots, - start: ctx.query.start, - search: ctx.query.search, - } - const input = { + const query = + ctx.query.cursor !== undefined + ? yield* SessionsCursor.parse(ctx.query.cursor).pipe( + Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })), + ) + : ctx.query + const sessions = yield* session.list({ + ...query, + workspaceID: query.workspace, limit: ctx.query.limit ?? DefaultSessionsLimit, - order, - workspaceID: filters.workspaceID, - search: filters.search, - cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, - } - const sessions = yield* session.list( - filters.directory - ? { - ...input, - directory: AbsolutePath.make(filters.directory), - } - : input, - ) + }) const first = sessions[0] const last = sessions.at(-1) return { items: sessions, cursor: { - previous: first ? sessionCursor.encode(first, order, "previous", filters) : undefined, - next: last ? sessionCursor.encode(last, order, "next", filters) : undefined, + previous: first + ? SessionsCursor.make({ + ...query, + anchor: { + id: first.id, + time: DateTime.toEpochMillis(first.time.created), + direction: "previous", + }, + }) + : undefined, + next: last + ? SessionsCursor.make({ + ...query, + anchor: { + id: last.id, + time: DateTime.toEpochMillis(last.time.created), + direction: "next", + }, + }) + : undefined, }, } }), diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index d9f2b56cb0d..064bcc97e28 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -25,7 +25,6 @@ import { } from "../../src/server/routes/instance/httpapi/groups/session" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" -import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -55,7 +54,6 @@ const openApiDriftRoutes = [ { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery }, { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery }, { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery }, - { method: "get", path: "/api/session", query: V2SessionsQuery }, { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery }, ] satisfies Array<{ method: Method; path: string; query: QuerySchema }> @@ -72,8 +70,6 @@ const numericSdkQueryParams = [ name: "limit", schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, }, - { method: "get", path: "/api/session", name: "limit", schema: { type: "number" } }, - { method: "get", path: "/api/session", name: "start", schema: { type: "number" } }, { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, ] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> @@ -81,7 +77,6 @@ const booleanSdkQueryParams = [ { method: "get", path: ExperimentalPaths.session, name: "roots" }, { method: "get", path: ExperimentalPaths.session, name: "archived" }, { method: "get", path: SessionPaths.list, name: "roots" }, - { method: "get", path: "/api/session", name: "roots" }, ] satisfies Array<{ method: Method; path: string; name: string }> const queryParamPatterns = [ @@ -239,7 +234,7 @@ describe("httpapi query schema drift", () => { ], }, path: "/fixture", - query: { fields: {} }, + query: Schema.Struct({}), }), ).toThrow("advertises query params not accepted by runtime schema") }), diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 63ec957845a..63cd012c152 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -444,17 +444,27 @@ describe("session HttpApi", () => { yield* insertLegacyAssistantMessage(session.id, 1) yield* insertLegacyAssistantMessage(session.id, 2) - const sessionPage = yield* request(`/api/session?limit=1`, { headers }) + const sessionPage = yield* request( + `/api/session?${new URLSearchParams({ + limit: "1", + order: "asc", + directory: test.directory, + search: "v2", + })}`, + { headers }, + ) const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next expect(sessionCursor).toBeTruthy() - - const cursorWithFilter = yield* request(`/api/session?cursor=${sessionCursor}&search=v2`, { headers }) - expect(cursorWithFilter.status).toBe(400) - expect(yield* responseJson(cursorWithFilter)).toMatchObject({ - _tag: "InvalidCursorError", - message: "Cursor cannot be combined with order or filters", + expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({ + order: "asc", + directory: test.directory, + search: "v2", + anchor: { id: session.id, direction: "next" }, }) + const sessionNextPage = yield* request(`/api/session?cursor=${sessionCursor}`, { headers }) + expect(sessionNextPage.status).toBe(200) + const invalidSessionCursor = yield* request(`/api/session?cursor=invalid`, { headers }) expect(invalidSessionCursor.status).toBe(400) expect(yield* responseJson(invalidSessionCursor)).toMatchObject({ @@ -462,21 +472,11 @@ describe("session HttpApi", () => { message: "Invalid cursor", }) - const mismatchedRouting = yield* request(`/api/session?cursor=${sessionCursor}&directory=/elsewhere`, { - headers, - }) - expect(mismatchedRouting.status).toBe(400) - expect(yield* responseJson(mismatchedRouting)).toMatchObject({ - _tag: "InvalidCursorError", - message: "Cursor does not match requested directory or workspace", - }) - const invalidWorkspace = yield* request(`/api/session?workspace=bad`, { headers }) expect(invalidWorkspace.status).toBe(400) expect(yield* responseJson(invalidWorkspace)).toMatchObject({ _tag: "InvalidRequestError", - message: "Invalid workspace query parameter", - field: "workspace", + kind: "Query", }) const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index d99cc4b1833..5fbc54e6506 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4342,14 +4342,13 @@ export class Session3 extends HeyApiClient { */ public list( parameters?: { - directory?: string workspace?: string limit?: number order?: "asc" | "desc" - path?: string - roots?: boolean | "true" | "false" - start?: number search?: string + directory?: string + project?: string + subpath?: string cursor?: string }, options?: Options, @@ -4359,14 +4358,13 @@ export class Session3 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "query", key: "limit" }, { in: "query", key: "order" }, - { in: "query", key: "path" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, { in: "query", key: "search" }, + { in: "query", key: "directory" }, + { in: "query", key: "project" }, + { in: "query", key: "subpath" }, { in: "query", key: "cursor" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c753ac5ebf4..cb10be2950a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -7969,16 +7969,15 @@ export type V2SessionListData = { body?: never path?: never query?: { - directory?: string workspace?: string limit?: number order?: "asc" | "desc" - path?: string - roots?: boolean | "true" | "false" - start?: number search?: string + directory?: string + project?: string + subpath?: string /** - * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters. + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. */ cursor?: string }